From fc17891f5f5ace2c2e305f53a324f214a302d2b6 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:44:26 -0300 Subject: [PATCH 1/9] chore(integration): centralize constants in a dedicated module Created a `constants.py` file to centralize all constants used in the test framework. This change improves maintainability by providing a single location for managing constants and makes it easier to understand their purpose and usage across the tests. --- tests/example/bitcoin.py | 19 ++++++++----------- tests/example/functional.py | 19 ++++++++++--------- tests/example/utreexod.py | 23 ++++++++--------------- tests/floresta-cli/getbestblockhash.py | 5 ++--- tests/floresta-cli/getblockchaininfo.py | 14 ++++++++------ tests/floresta-cli/getblockcount.py | 3 ++- tests/floresta-cli/getblockhash.py | 5 ++--- tests/floresta-cli/getblockheader.py | 6 ++---- tests/floresta-cli/gettxout.py | 5 +++-- tests/florestad/reorg-chain.py | 3 ++- tests/test_framework/constants.py | 15 +++++++++++++++ tests/test_framework/rpc/bitcoin.py | 6 +++--- tests/test_framework/util.py | 5 +++-- 13 files changed, 68 insertions(+), 60 deletions(-) create mode 100644 tests/test_framework/constants.py diff --git a/tests/example/bitcoin.py b/tests/example/bitcoin.py index 6c9f32abe..25e546381 100644 --- a/tests/example/bitcoin.py +++ b/tests/example/bitcoin.py @@ -7,6 +7,11 @@ from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import ( + GENESIS_BLOCK_HASH, + GENESIS_BLOCK_DIFFICULTY_FLOAT, + TEST_CHAIN, +) class BitcoindTest(FlorestaTestFramework): @@ -17,14 +22,6 @@ class BitcoindTest(FlorestaTestFramework): the test do and the expected result in the docstrings """ - expected_chain = "regtest" - expected_height = 0 - expected_headers = 0 - expected_blockhash = ( - "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" - ) - expected_difficulty = 1 - def set_test_params(self): """ Here we define setup for test adding a node definition @@ -54,9 +51,9 @@ def run_test(self): # to perform some kind of action response = self.bitcoind.rpc.get_blockchain_info() - self.assertEqual(response["chain"], BitcoindTest.expected_chain) - self.assertEqual(response["bestblockhash"], BitcoindTest.expected_blockhash) - self.assertTrue(response["difficulty"] > 0) + self.assertEqual(response["chain"], TEST_CHAIN) + self.assertEqual(response["bestblockhash"], GENESIS_BLOCK_HASH) + self.assertEqual(response["difficulty"], GENESIS_BLOCK_DIFFICULTY_FLOAT) self.stop() diff --git a/tests/example/functional.py b/tests/example/functional.py index 50bfa4d0c..26db37771 100644 --- a/tests/example/functional.py +++ b/tests/example/functional.py @@ -7,6 +7,12 @@ from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import ( + GENESIS_BLOCK_HEIGHT, + GENESIS_BLOCK_HASH, + GENESIS_BLOCK_DIFFICULTY_INT, + GENESIS_BLOCK_LEAF_COUNT, +) class FunctionalTest(FlorestaTestFramework): @@ -17,11 +23,6 @@ class FunctionalTest(FlorestaTestFramework): the test do and the expected result in the docstrings """ - expected_height = 0 - expected_block = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" - expected_difficulty = 1 - expected_leaf_count = 0 - def set_test_params(self): """ Here we define setup for test adding a node definition @@ -54,10 +55,10 @@ def run_test(self): # Make assertions with our framework. Avoid usage of # native `assert` clauses. For more information, see # https://github.com/getfloresta/Floresta/issues/426 - self.assertEqual(inf_response["height"], FunctionalTest.expected_height) - self.assertEqual(inf_response["best_block"], FunctionalTest.expected_block) - self.assertEqual(inf_response["difficulty"], FunctionalTest.expected_difficulty) - self.assertEqual(inf_response["leaf_count"], FunctionalTest.expected_leaf_count) + self.assertEqual(inf_response["height"], GENESIS_BLOCK_HEIGHT) + self.assertEqual(inf_response["best_block"], GENESIS_BLOCK_HASH) + self.assertEqual(inf_response["difficulty"], GENESIS_BLOCK_DIFFICULTY_INT) + self.assertEqual(inf_response["leaf_count"], GENESIS_BLOCK_LEAF_COUNT) # stop nodes self.stop() diff --git a/tests/example/utreexod.py b/tests/example/utreexod.py index 41940e865..46b433ff0 100644 --- a/tests/example/utreexod.py +++ b/tests/example/utreexod.py @@ -7,6 +7,11 @@ from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import ( + TEST_CHAIN, + GENESIS_BLOCK_HASH, + GENESIS_BLOCK_DIFFICULTY_INT, +) class UtreexodTest(FlorestaTestFramework): @@ -17,14 +22,6 @@ class UtreexodTest(FlorestaTestFramework): the test do and the expected result in the docstrings """ - expected_chain = "regtest" - expected_height = 0 - expected_headers = 0 - expected_blockhash = ( - "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" - ) - expected_difficulty = 1 - def set_test_params(self): """ Here we define setup for test adding a node definition @@ -54,13 +51,9 @@ def run_test(self): # to perform some kind of action utreexo_response = self.utreexod.rpc.get_blockchain_info() - self.assertEqual(utreexo_response["chain"], UtreexodTest.expected_chain) - self.assertEqual( - utreexo_response["bestblockhash"], UtreexodTest.expected_blockhash - ) - self.assertEqual( - utreexo_response["difficulty"], UtreexodTest.expected_difficulty - ) + self.assertEqual(utreexo_response["chain"], TEST_CHAIN) + self.assertEqual(utreexo_response["bestblockhash"], GENESIS_BLOCK_HASH) + self.assertEqual(utreexo_response["difficulty"], GENESIS_BLOCK_DIFFICULTY_INT) self.stop() diff --git a/tests/floresta-cli/getbestblockhash.py b/tests/floresta-cli/getbestblockhash.py index a8e860f82..ff73fb5ed 100644 --- a/tests/floresta-cli/getbestblockhash.py +++ b/tests/floresta-cli/getbestblockhash.py @@ -9,6 +9,7 @@ import time from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import WALLET_ADDRESS class GetBestblockhashTest(FlorestaTestFramework): @@ -24,8 +25,6 @@ class GetBestblockhashTest(FlorestaTestFramework): of floresta and utreexod, respectively. """ - best_block = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" - def set_test_params(self): """ Setup a florestad node and a utreexod mining node @@ -35,7 +34,7 @@ def set_test_params(self): self.utreexod = self.add_node_extra_args( variant=NodeType.UTREEXOD, extra_args=[ - "--miningaddr=bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y", + f"--miningaddr={WALLET_ADDRESS}", "--prune=0", ], ) diff --git a/tests/floresta-cli/getblockchaininfo.py b/tests/floresta-cli/getblockchaininfo.py index 04695b9b2..242cfcc99 100644 --- a/tests/floresta-cli/getblockchaininfo.py +++ b/tests/floresta-cli/getblockchaininfo.py @@ -6,6 +6,11 @@ from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import ( + GENESIS_BLOCK_HASH, + GENESIS_BLOCK_DIFFICULTY_INT, + GENESIS_BLOCK_HEIGHT, +) class GetBlockchaininfoTest(FlorestaTestFramework): @@ -14,9 +19,6 @@ class GetBlockchaininfoTest(FlorestaTestFramework): """ nodes = [-1] - best_block = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" - difficulty = 1 - height = 0 ibd = True latest_block_time = 1296688602 latest_work = "0000000000000000000000000000000000000000000000000000000000000002" @@ -41,9 +43,9 @@ def run_test(self): # Test assertions response = self.florestad.rpc.get_blockchain_info() - self.assertEqual(response["best_block"], GetBlockchaininfoTest.best_block) - self.assertEqual(response["difficulty"], GetBlockchaininfoTest.difficulty) - self.assertEqual(response["height"], GetBlockchaininfoTest.height) + self.assertEqual(response["best_block"], GENESIS_BLOCK_HASH) + self.assertEqual(response["difficulty"], GENESIS_BLOCK_DIFFICULTY_INT) + self.assertEqual(response["height"], GENESIS_BLOCK_HEIGHT) self.assertEqual(response["ibd"], GetBlockchaininfoTest.ibd) self.assertEqual( response["latest_block_time"], GetBlockchaininfoTest.latest_block_time diff --git a/tests/floresta-cli/getblockcount.py b/tests/floresta-cli/getblockcount.py index ae18700b7..2f7f0c96f 100644 --- a/tests/floresta-cli/getblockcount.py +++ b/tests/floresta-cli/getblockcount.py @@ -8,6 +8,7 @@ import time from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import WALLET_ADDRESS class GetBlockCountTest(FlorestaTestFramework): @@ -30,7 +31,7 @@ def set_test_params(self): self.utreexod = self.add_node_extra_args( variant=NodeType.UTREEXOD, extra_args=[ - "--miningaddr=bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y", + f"--miningaddr={WALLET_ADDRESS}", "--prune=0", ], ) diff --git a/tests/floresta-cli/getblockhash.py b/tests/floresta-cli/getblockhash.py index f09e34473..5f3d6f313 100644 --- a/tests/floresta-cli/getblockhash.py +++ b/tests/floresta-cli/getblockhash.py @@ -8,6 +8,7 @@ import time from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import WALLET_ADDRESS class GetBlockhashTest(FlorestaTestFramework): @@ -17,8 +18,6 @@ class GetBlockhashTest(FlorestaTestFramework): the blockhashes match between floresta, utreexod, and bitcoind. """ - best_block = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" - def set_test_params(self): """ Setup a single node @@ -31,7 +30,7 @@ def set_test_params(self): self.utreexod = self.add_node_extra_args( variant=NodeType.UTREEXOD, extra_args=[ - "--miningaddr=bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y", + f"--miningaddr={WALLET_ADDRESS}", "--prune=0", ], ) diff --git a/tests/floresta-cli/getblockheader.py b/tests/floresta-cli/getblockheader.py index b7527875f..f477904a7 100644 --- a/tests/floresta-cli/getblockheader.py +++ b/tests/floresta-cli/getblockheader.py @@ -6,6 +6,7 @@ from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import GENESIS_BLOCK_HASH class GetBlockheaderHeightZeroTest(FlorestaTestFramework): @@ -28,7 +29,6 @@ class GetBlockheaderHeightZeroTest(FlorestaTestFramework): nodes = [-1] version = 1 - blockhash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" prev_blockhash = "0000000000000000000000000000000000000000000000000000000000000000" merkle_root = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" time = 1296688602 @@ -49,9 +49,7 @@ def run_test(self): self.run_node(self.florestad) # Test assertions - response = self.florestad.rpc.get_blockheader( - GetBlockheaderHeightZeroTest.blockhash - ) + response = self.florestad.rpc.get_blockheader(GENESIS_BLOCK_HASH) self.assertEqual(response["version"], GetBlockheaderHeightZeroTest.version) self.assertEqual( response["prev_blockhash"], GetBlockheaderHeightZeroTest.prev_blockhash diff --git a/tests/floresta-cli/gettxout.py b/tests/floresta-cli/gettxout.py index f704c875e..a2684bb98 100644 --- a/tests/floresta-cli/gettxout.py +++ b/tests/floresta-cli/gettxout.py @@ -9,12 +9,13 @@ import os from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import WALLET_ADDRESS # TODO Use many addresses types as possible to test the gettxout command WALLET_CONFIG = "\n".join( [ "[wallet]", - 'addresses = [ "bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y" ]', + f'addresses = [ "{WALLET_ADDRESS}" ]', ] ) @@ -46,7 +47,7 @@ def set_test_params(self): self.utreexod = self.add_node_extra_args( variant=NodeType.UTREEXOD, extra_args=[ - "--miningaddr=bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y", + f"--miningaddr={WALLET_ADDRESS}", "--prune=0", ], ) diff --git a/tests/florestad/reorg-chain.py b/tests/florestad/reorg-chain.py index 3ddc2e806..7c1194a72 100644 --- a/tests/florestad/reorg-chain.py +++ b/tests/florestad/reorg-chain.py @@ -12,6 +12,7 @@ from test_framework import FlorestaTestFramework from test_framework.node import NodeType +from test_framework.constants import WALLET_ADDRESS class ChainReorgTest(FlorestaTestFramework): @@ -25,7 +26,7 @@ def set_test_params(self): self.utreexod = self.add_node_extra_args( variant=NodeType.UTREEXOD, extra_args=[ - "--miningaddr=bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y", + f"--miningaddr={WALLET_ADDRESS}", "--utreexoproofindex", "--prune=0", ], diff --git a/tests/test_framework/constants.py b/tests/test_framework/constants.py new file mode 100644 index 000000000..2b798b618 --- /dev/null +++ b/tests/test_framework/constants.py @@ -0,0 +1,15 @@ +""" +This module contains constants used throughout the Floresta tests. +""" + +import os + +GENESIS_BLOCK_HEIGHT = 0 +GENESIS_BLOCK_HASH = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" +GENESIS_BLOCK_DIFFICULTY_INT = 1 +GENESIS_BLOCK_DIFFICULTY_FLOAT = 4.656542373906925e-10 +GENESIS_BLOCK_LEAF_COUNT = 0 +TEST_CHAIN = "regtest" +FLORESTA_TEMP_DIR = os.getenv("FLORESTA_TEMP_DIR") + +WALLET_ADDRESS = "bcrt1q427ze5mrzqupzyfmqsx9gxh7xav538yk2j4cft" diff --git a/tests/test_framework/rpc/bitcoin.py b/tests/test_framework/rpc/bitcoin.py index 3165c6faa..d1b3b88a8 100644 --- a/tests/test_framework/rpc/bitcoin.py +++ b/tests/test_framework/rpc/bitcoin.py @@ -5,6 +5,7 @@ """ from test_framework.rpc.base import BaseRPC +from test_framework.constants import WALLET_ADDRESS class BitcoinRPC(BaseRPC): @@ -33,7 +34,7 @@ def generate_block_to_address(self, nblocks: int, address: str) -> list: def generate_block(self, nblocks: int) -> list: """ - Mine blocks immediately to a address(bcrt1q3ml87jemlfvk7lq8gfs7pthvj5678ndnxnw9ch) using + Mine blocks immediately to a address(WALLET_ADDRESS) using `generate_block_to_address(nblocks, address)` Args: @@ -42,5 +43,4 @@ def generate_block(self, nblocks: int) -> list: Returns: A list of block hashes of the newly mined blocks """ - address = "bcrt1q3ml87jemlfvk7lq8gfs7pthvj5678ndnxnw9ch" - return self.generate_block_to_address(nblocks, address) + return self.generate_block_to_address(nblocks, WALLET_ADDRESS) diff --git a/tests/test_framework/util.py b/tests/test_framework/util.py index 01cf9b562..10bbb6566 100644 --- a/tests/test_framework/util.py +++ b/tests/test_framework/util.py @@ -9,6 +9,7 @@ create_pkcs8_private_key, create_pkcs8_self_signed_certificate, ) +from test_framework.constants import FLORESTA_TEMP_DIR class Utility: @@ -22,12 +23,12 @@ def get_integration_test_dir(): Get path for florestad used in integration tests, generally set on $FLORESTA_TEMP_DIR/binaries """ - if os.getenv("FLORESTA_TEMP_DIR") is None: + if FLORESTA_TEMP_DIR is None: raise RuntimeError( "FLORESTA_TEMP_DIR not set. " + " Please set it to the path of the integration test directory." ) - return os.getenv("FLORESTA_TEMP_DIR") + return FLORESTA_TEMP_DIR @staticmethod def get_logs_dir(): From 04c5339b8e4b5c843971b2524e1d989dffdcc8cb Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:42:28 -0300 Subject: [PATCH 2/9] docs(cli): clarify documentation for wallet descriptor option Updated the documentation for the `wallet_descriptor` option to explicitly describe its behavior with descriptors. The previous text incorrectly referred to xpubs, which could cause confusion. --- bin/florestad/src/cli.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bin/florestad/src/cli.rs b/bin/florestad/src/cli.rs index 0a607be2f..137711d8b 100644 --- a/bin/florestad/src/cli.rs +++ b/bin/florestad/src/cli.rs @@ -63,14 +63,13 @@ pub struct Cli { pub wallet_xpub: Option>, #[arg(long, value_name = "DESCRIPTOR")] - /// Add an output descriptor to our wallet + /// Add an output descriptor to our wallet. /// - /// This option can be passed many times, and will accept any valid output descriptor. - /// You only need to pass this once, but there's no harm in passing it more than once. - /// After you start florestad at least once, passing some xpub, florestad - /// will follow the first 100 addresses derived from this xpub on each keychain and - /// cache any transactions where those addresses appear. You can use either the integrated - /// json-rpc or electrum server to fetch an address's history, balance and utxos. + /// This option can be passed multiple times, as long as each descriptor is valid. + /// For each valid descriptor, the node will derive the first 100 addresses and cache any + /// transactions related to those addresses. + /// You can use the integrated JSON-RPC or Electrum server to fetch the transaction history, + /// balance, and UTXOs for these addresses. pub wallet_descriptor: Option>, #[arg(long, value_name = "BLOCK_HASH|0", default_value = "hardcoded", value_parser = parse_assume_valid)] From 629ad69ff97238d6d35ebc725b0c8579fd314fe2 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:04:57 -0300 Subject: [PATCH 3/9] fix(wallet): handle duplicate descriptors in push_descriptor method Previously, it was possible to save the same descriptor multiple times, leading to redundancy and potential inconsistencies. Adds a verification step in the `push_descriptor` method to ensure that a descriptor is only added if it does not already exist. --- crates/floresta-node/src/florestad.rs | 12 +++++++----- crates/floresta-node/src/json_rpc/server.rs | 6 +++--- crates/floresta-watch-only/src/lib.rs | 14 +++++++++++--- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index 351674019..545ae43c8 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -27,6 +27,7 @@ use floresta_electrum::electrum_protocol::ElectrumServer; use floresta_mempool::Mempool; use floresta_watch_only::kv_database::KvDatabase; use floresta_watch_only::AddressCache; +use floresta_watch_only::WatchOnlyError; use floresta_wire::address_man::AddressMan; use floresta_wire::node::running_ctx::RunningNode; use floresta_wire::node::UtreexoNode; @@ -745,11 +746,12 @@ impl Florestad { // Add the configured descriptors and addresses to the wallet for descriptor in setup.descriptors { - let descriptor = descriptor.to_string(); - let is_cached = wallet.is_cached(&descriptor)?; - - if !is_cached { - wallet.push_descriptor(&descriptor)?; + if let Err(e) = wallet.push_descriptor(&descriptor.to_string()) { + if let WatchOnlyError::DescriptorDuplicate = e { + warn!("Descriptor already exists in wallet, skipping: {descriptor}"); + } else { + return Err(FlorestadError::from(e)); + } } } for addresses in setup.addresses { diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 22118068e..861e2d064 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -102,6 +102,9 @@ impl RpcImpl { let desc = slice::from_ref(&descriptor); let mut parsed = parse_descriptors(desc)?; + self.wallet.push_descriptor(&descriptor)?; + debug!("Descriptor pushed: {descriptor}"); + // It's ok to unwrap because we know there is at least one element in the vector let addresses = parsed.pop().unwrap(); let addresses = (0..100) @@ -131,9 +134,6 @@ impl RpcImpl { addresses, chain, wallet, cfilters, node, None, None, )); - self.wallet.push_descriptor(&descriptor)?; - debug!("Descriptor pushed: {descriptor}"); - Ok(true) } diff --git a/crates/floresta-watch-only/src/lib.rs b/crates/floresta-watch-only/src/lib.rs index d53a9170d..7c0eebc18 100644 --- a/crates/floresta-watch-only/src/lib.rs +++ b/crates/floresta-watch-only/src/lib.rs @@ -45,6 +45,7 @@ pub enum WatchOnlyError { WalletNotInitialized, TransactionNotFound, DatabaseError(DatabaseError), + DescriptorDuplicate, } impl Display for WatchOnlyError { @@ -59,6 +60,9 @@ impl Display for WatchOnlyError { WatchOnlyError::DatabaseError(e) => { write!(f, "Database error: {e:?}") } + WatchOnlyError::DescriptorDuplicate => { + write!(f, "Descriptor is already cached") + } } } } @@ -626,10 +630,10 @@ impl AddressCache { } /// Tells whether or not a descriptor is already cached - pub fn is_cached(&self, desc: &String) -> Result> { + pub fn is_cached(&self, desc: &str) -> Result> { let inner = self.inner.read().expect("poisoned lock"); let known_descs = inner.database.descs_get()?; - Ok(known_descs.contains(desc)) + Ok(known_descs.contains(&String::from(desc))) } /// Tells whether an address is already cached @@ -639,6 +643,10 @@ impl AddressCache { } pub fn push_descriptor(&self, descriptor: &str) -> Result<(), WatchOnlyError> { + if self.is_cached(&String::from(descriptor))? { + return Err(WatchOnlyError::DescriptorDuplicate); + } + let inner = self.inner.write().expect("poisoned lock"); Ok(inner.database.desc_save(descriptor)?) } @@ -975,7 +983,7 @@ mod test { // [is_cached], [push_descriptor] let desc = "wsh(sortedmulti(1,[54ff5a12/48h/1h/0h/2h]tpubDDw6pwZA3hYxcSN32q7a5ynsKmWr4BbkBNHydHPKkM4BZwUfiK7tQ26h7USm8kA1E2FvCy7f7Er7QXKF8RNptATywydARtzgrxuPDwyYv4x/<0;1>/*,[bcf969c0/48h/1h/0h/2h]tpubDEFdgZdCPgQBTNtGj4h6AehK79Jm4LH54JrYBJjAtHMLEAth7LuY87awx9ZMiCURFzFWhxToRJK6xp39aqeJWrG5nuW3eBnXeMJcvDeDxfp/<0;1>/*))#fuw35j0q"; cache.push_descriptor(desc).unwrap(); - assert!(cache.is_cached(&desc.to_string()).unwrap()); + assert!(cache.is_cached(desc).unwrap()); // [derive_addresses] cache.derive_addresses().unwrap(); From 39d13497945f74e20b96f56021f099745c7c190b Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:07:03 -0300 Subject: [PATCH 4/9] fix(wallet): improve xpub parsing and descriptor generation - Updated xpub parsing to reject xpriv and xpubs related to multisig, ensuring only standard public keys are accepted for descriptor generation. - Fixed an issue in descriptor generation by xpub where only `wpkh` descriptors were being created. Descriptors are now generated dynamically as `wpkh`, `pkh`, or `sh-wpkh`, depending on the xpub provided. - Added a network validation step to ensure the xpub matches the network the node is running on. - Added \_typos\.toml configuration to prevent https://github.com/crate-ci/typos from analyzing words with lengths between 32 and 150 characters, as these correspond to hashes, public keys, private keys, XPUBs, descriptors, and Bitcoin addresses. --- .github/workflows/rust.yml | 2 + _typos.toml | 9 + crates/floresta-node/src/error.rs | 6 + crates/floresta-node/src/slip132.rs | 226 +++++++++++++++++++---- crates/floresta-node/src/wallet_input.rs | 201 ++++++++++++++------ justfile | 2 +- 6 files changed, 349 insertions(+), 97 deletions(-) create mode 100644 _typos.toml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1763e2963..418cd16d8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -27,6 +27,8 @@ jobs: - name: Spell check uses: crate-ci/typos@v1 + with: + config: ./_typos.toml - name: Run cargo fmt run: cargo +nightly fmt --all --check diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 000000000..b98e270e4 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,9 @@ +[default] +extend-ignore-re = [ + # This regex is used to prevent https://github.com/crate-ci/typos from analyzing words + # with lengths between 32 and 150 characters. These lengths correspond to hashes, + # public keys, private keys, XPUBs, descriptors, and Bitcoin addresses. + # These items are usually randomly generated for testing and may contain patterns + # that would be flagged as errors but should not be. + "[0-9A-Za-z]{32,150}+", +] \ No newline at end of file diff --git a/crates/floresta-node/src/error.rs b/crates/floresta-node/src/error.rs index 02b582bb1..9003c4d69 100644 --- a/crates/floresta-node/src/error.rs +++ b/crates/floresta-node/src/error.rs @@ -124,6 +124,9 @@ pub enum FlorestadError { /// Load a flat chain store error. CouldNotLoadFlatChainStore(BlockchainError), + + /// Xpub network mismatch error. + XpubNetworkMismatch(String), } impl std::fmt::Display for FlorestadError { @@ -225,6 +228,9 @@ impl std::fmt::Display for FlorestadError { FlorestadError::CouldNotLoadFlatChainStore(err) => { write!(f, "Failure while loading flat chainstore: {err:?}") } + FlorestadError::XpubNetworkMismatch(xpub) => { + write!(f, "Xpub network mismatch: {xpub}") + } } } } diff --git a/crates/floresta-node/src/slip132.rs b/crates/floresta-node/src/slip132.rs index 9b0bf28d4..1953ac7e2 100644 --- a/crates/floresta-node/src/slip132.rs +++ b/crates/floresta-node/src/slip132.rs @@ -11,7 +11,6 @@ use std::fmt::Debug; use bitcoin::base58; use bitcoin::bip32; -use bitcoin::bip32::Xpriv; use bitcoin::bip32::Xpub; /// Magical version bytes for xpub: bitcoin mainnet public key for P2PKH or P2SH @@ -106,6 +105,43 @@ pub enum Error { /// failure in rust bitcoin library InternalFailure, + + /// No support for xpriv + NoSupportXpriv, + + /// No multisig support via xpub. + NoSupportXpubMultisig, +} + +fn extract_slip132_prefix(s: &str) -> Result<[u8; 4], Error> { + let data = base58::decode_check(s)?; + let mut prefix = [0u8; 4]; + prefix.copy_from_slice(&data[0..4]); + + validate_slip132_prefix(prefix)?; + + Ok(prefix) +} + +fn validate_slip132_prefix(prefix: [u8; 4]) -> Result<(), Error> { + match prefix { + VERSION_MAGIC_XPUB | VERSION_MAGIC_YPUB | VERSION_MAGIC_ZPUB | VERSION_MAGIC_TPUB + | VERSION_MAGIC_UPUB | VERSION_MAGIC_VPUB => Ok(()), + + VERSION_MAGIC_XPRV | VERSION_MAGIC_YPRV | VERSION_MAGIC_ZPRV | VERSION_MAGIC_TPRV + | VERSION_MAGIC_UPRV | VERSION_MAGIC_VPRV => Err(Error::NoSupportXpriv), + + VERSION_MAGIC_YPUB_MULTISIG + | VERSION_MAGIC_ZPUB_MULTISIG + | VERSION_MAGIC_UPUB_MULTISIG + | VERSION_MAGIC_VPUB_MULTISIG + | VERSION_MAGIC_YPRV_MULTISIG + | VERSION_MAGIC_ZPRV_MULTISIG + | VERSION_MAGIC_UPRV_MULTISIG + | VERSION_MAGIC_VPRV_MULTISIG => Err(Error::NoSupportXpubMultisig), + + _ => Err(Error::UnknownSlip32Prefix), + } } impl From for Error { @@ -143,20 +179,11 @@ impl FromSlip132 for Xpub { fn from_slip132_str(s: &str) -> Result { let mut data = base58::decode_check(s)?; - let mut prefix = [0u8; 4]; - prefix.copy_from_slice(&data[0..4]); + let prefix: [u8; 4] = extract_slip132_prefix(s)?; let slice = match prefix { - VERSION_MAGIC_XPUB - | VERSION_MAGIC_YPUB - | VERSION_MAGIC_ZPUB - | VERSION_MAGIC_YPUB_MULTISIG - | VERSION_MAGIC_ZPUB_MULTISIG => VERSION_MAGIC_XPUB, - - VERSION_MAGIC_TPUB - | VERSION_MAGIC_UPUB - | VERSION_MAGIC_VPUB - | VERSION_MAGIC_UPUB_MULTISIG - | VERSION_MAGIC_VPUB_MULTISIG => VERSION_MAGIC_TPUB, + VERSION_MAGIC_XPUB | VERSION_MAGIC_YPUB | VERSION_MAGIC_ZPUB => VERSION_MAGIC_XPUB, + + VERSION_MAGIC_TPUB | VERSION_MAGIC_UPUB | VERSION_MAGIC_VPUB => VERSION_MAGIC_TPUB, _ => return Err(Error::UnknownSlip32Prefix), }; @@ -168,31 +195,158 @@ impl FromSlip132 for Xpub { } } -impl FromSlip132 for Xpriv { - fn from_slip132_str(s: &str) -> Result { - let mut data = base58::decode_check(s)?; +/// Generates a descriptor based on the provided xpub. +/// The descriptor type is determined by the xpub's prefix: +/// - P2PKH for xpub/tpub (Legacy addresses) +/// - P2WPKH-P2SH for ypub/upub (SegWit nested in P2SH) +/// - P2WPKH for zpub/vpub (Native SegWit) +pub fn generate_descriptor_from_xpub(s: &str, change: bool) -> Result { + let index = if change { 1 } else { 0 }; + let xpub = Xpub::from_slip132_str(s)?; - let mut prefix = [0u8; 4]; - prefix.copy_from_slice(&data[0..4]); - let slice = match prefix { - VERSION_MAGIC_XPRV - | VERSION_MAGIC_YPRV - | VERSION_MAGIC_ZPRV - | VERSION_MAGIC_YPRV_MULTISIG - | VERSION_MAGIC_ZPRV_MULTISIG => VERSION_MAGIC_XPRV, - - VERSION_MAGIC_TPRV - | VERSION_MAGIC_UPRV - | VERSION_MAGIC_VPRV - | VERSION_MAGIC_UPRV_MULTISIG - | VERSION_MAGIC_VPRV_MULTISIG => VERSION_MAGIC_TPRV, + let prefix = extract_slip132_prefix(s)?; - _ => return Err(Error::UnknownSlip32Prefix), - }; - data[0..4].copy_from_slice(&slice); + match prefix { + VERSION_MAGIC_XPUB | VERSION_MAGIC_TPUB => Ok(format!("pkh({xpub}/{index}/*)")), + VERSION_MAGIC_YPUB | VERSION_MAGIC_UPUB => Ok(format!("sh(wpkh({xpub}/{index}/*))")), + VERSION_MAGIC_ZPUB | VERSION_MAGIC_VPUB => Ok(format!("wpkh({xpub}/{index}/*)")), - let xprv = Xpriv::decode(&data)?; + _ => Err(Error::UnknownSlip32Prefix), + } +} + +/// Checks if the xpub belongs to the mainnet based on its prefix. +pub fn is_xpub_mainnet(s: &str) -> Result { + let prefix = extract_slip132_prefix(s)?; + match prefix { + VERSION_MAGIC_XPUB | VERSION_MAGIC_YPUB | VERSION_MAGIC_ZPUB => Ok(true), + + VERSION_MAGIC_TPUB | VERSION_MAGIC_UPUB | VERSION_MAGIC_VPUB => Ok(false), + + _ => Err(Error::UnknownSlip32Prefix), + } +} - Ok(xprv) +#[cfg(test)] +mod test { + use super::*; + + const XPUB: &str = "xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs"; + const XPRIV: &str = "xprv9yQNNBquqwFaHWkSYMEr96ihjaC444YACkJVeo5SpcZW5knqmtxJNMNr781jwtB2PRV1BJMdwAMrQt7DHUumiA7h1BGp2C4h2C1geFtYMzs"; + const YPUB: &str = "ypub6XmBfjfmuYD1bjv5RCEHU8jD1NPGZh6NRTGDB8ndQsd7MPnzhDhAsdrF9sK8Z4G9FvcFBHoGsZqhsDHtenca3K5QigYWVKXvkAx6HBxVGYM"; + const YPRIV: &str = "yprvAJmqGE8t5AeiPFqcKAhH6znUTLYnAENX4ELcNkP1rY68UbTr9gNvKqXmJZe1RsaVZquKb9UR2KkwUTGcN337oFKkyqFRmPKhpdcLLiVQMZ6"; + const ZPUB: &str = "zpub6rFvSvP5VbpXwej2L5WseLfxfdUzSczs9DK9v9mpXgXNqjFhtfUTRGkQKr7sXKNyrrzhd2LCysGqts1oT3b1PJji16xWzcmNMfhmZ8kkLZ1"; + const ZPRIV: &str = "zprvAdGa3QrBfEGEjAeZE3ysHCjE7beW3AH1mzPZ7mNCyLzPxvvZM8ACsURvUbcnW5hV91XfdZNzi8jvPLZ4fubdG4qqKkFFojgd6JN67Hyy8xT"; + + const TPUB: &str = "tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5"; + const TPRIV: &str = "tprv8fR1EwR3VwXrtnuaNLTbvnJuvUS83ntbvjKpiRT3WvQmAG9KsLKXbR5UFKPAiRJKyDxiLd2uovStSJ3Qnov7SgkKK4mUcghwX7KHwjhSEFi"; + const UPUB: &str = "upub5E3Vhaq9uVmz426B5FME1csAY8tvQ8vRqt7WnGyiJ4CoknpyM2WJk4B6uSh2kud3r8RJHTzS5jLFnWNRThKZyew6tDX2eXGMyTvfa8AVwyK"; + const UPRIV: &str = "uprv9149J5JG58DgqY1hyDpDeUvRz74RzgCaUfBuyta6jifpszVpoVC4CFrd4D7E5QFmzdYJ1EuLFq9Ge4TvCAymG8cPt3QLz7UJ8Fpsiwgg7Lg"; + const VPUB: &str = "vpub5Zrsj9pYeJLwTfggbSQYZDdpEpZ4M1qB1EUKfXB9bjsookSNjM6c6eFTYfjb8KcGJV4ZqAYScBvC7hyDbbWKCHVcC6RETNJUfwUFvnHJM8Y"; + const VPRIV: &str = "vprv9LsXKeHeovneFBcDVQsYC5h5gniZwZ7Ke1Yis8mY3QLpvx7EBonMYqvyhMwbmAfR5SXLA2byEyPbB3uAajJhAr2NNeM91WfAwRS1KcjCeFo"; + + const YPRIV_MULTISIG: &str = "YprvARd5Qbw8ES2RHXALKfJ8v2hnKe3iDZNuG6kupDzPxXJicdKJ4Mc3TN8D1GNHsQsNTS7wGPts88TJ8pjKKCYLon9GopLzQ6fr9VcTWRUxbLq"; + const YPUB_MULTISIG: &str = "Ypub6ecRp7U24oaiW1EoRgq9HAeWsftCd26kdKgWccQ1WrqhVReSbtvJ1ASgrXLA4CSKw11yatzpmyYy3LraoW2E7kd7X32fTnwdqHnESpnyrKb"; + const ZPRIV_MULTISIG: &str = "ZprvAkTLiGc3P7Zu8pMTA25m87oHVcCAABNQBDH8bctHLXgbfj8XK1mc5RnM2UKssKXHs5Ek1sVRanor27Lt2txMc1psgA3Qz1VLRDg6u4gAyFo"; + const ZPUB_MULTISIG: &str = "Zpub6ySh7n8wDV8CMJRvG3cmVFk23e2eZe6FYSCjQ1HttsDaYXTfrZ5rdE6psjHk476FLe8nLNbPEduWvdU9XCSEuzJiPNj63hm871qsqUVx7kC"; + const UPRIV_MULTISIG: &str = "Uprv98J2BwFTdhrVtLPrzE9e5gKmdmTvT5Qubeg2geQrSVoCQE4P3iwny7VevSXwsnFgpseiGVWdHV36bgH4SQtHcqQsLTZJ4TPu4bMsx8v4vMj"; + const UPUB_MULTISIG: &str = "Upub5MHNbSnMU5Qo6pUL6FgeSpGWBoJQrY8kxsbdV2pTzqLBH2PXbGG3Wup8mhVp4ZpeJSYkazcawL8mWCQKviNAvoti3gEy89fgkPXetXJzNZt"; + const VPRIV_MULTSIG: &str = "Vprv19RbkhR2UPGc4edf2xogFVhtnjvvdpwMJgX4yug3xZufVdwzDXc1xUrQcnaQMZLgp3mPQVD5NQ9QFdwktTo1LgumfqiidCHCfTaY2MtjLrZ"; + const VPUB_MULTSIG: &str = "Vpub5g7du7TGckxGx7fSvcUGeuN1MmSroA8Fsz7rGRiMNqi4L8CkqvRc8yUGnuTQ4UUZi5fZLUD9PzVKPV1teQnBj3aJv1wPi4VB27bJH5b5suR"; + + fn create_invalid_slip32_base58(reference: &str) -> String { + let mut data = base58::decode_check(reference).unwrap(); + // Change prefix + data[3] = 0x21; + + // Remove checksum + data.truncate(data.len() - 4); + + base58::encode_check(&data) + } + + #[test] + fn test_check_unknown_slip32_prefix_error() { + let cases = [ + create_invalid_slip32_base58(XPUB), + create_invalid_slip32_base58(YPUB), + create_invalid_slip32_base58(ZPUB), + create_invalid_slip32_base58(TPUB), + create_invalid_slip32_base58(UPUB), + create_invalid_slip32_base58(VPUB), + ]; + + for key in cases.iter() { + let result = extract_slip132_prefix(key); + assert_eq!(result.err().unwrap(), Error::UnknownSlip32Prefix); + } + } + + #[test] + fn test_verify_multisig_prefix_error() { + let cases = &[ + YPRIV_MULTISIG, + YPUB_MULTISIG, + ZPRIV_MULTISIG, + ZPUB_MULTISIG, + UPRIV_MULTISIG, + UPUB_MULTISIG, + VPRIV_MULTSIG, + VPUB_MULTSIG, + ]; + for key in cases.iter() { + let result = extract_slip132_prefix(key); + assert_eq!(result.err().unwrap(), Error::NoSupportXpubMultisig); + } + } + + #[test] + fn test_check_xpriv_support_error() { + let cases = &[(XPRIV), (YPRIV), (ZPRIV), (TPRIV), (UPRIV), (VPRIV)]; + + for key in cases.iter() { + let result = extract_slip132_prefix(key); + assert_eq!(result.err().unwrap(), Error::NoSupportXpriv); + } + } + + #[test] + fn test_validate_network_xpub() { + let cases = &[ + (XPUB, true), + (YPUB, true), + (ZPUB, true), + (TPUB, false), + (UPUB, false), + (VPUB, false), + ]; + + for &(key, change) in cases.iter() { + let result = is_xpub_mainnet(key); + assert_eq!(result.unwrap(), change); + } + } + + #[test] + fn test_descriptor_generation_for_xpub() { + let cases: &[(&str, bool, &str); 12] = &[ + (XPUB, true, "pkh(xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs/1/*)"), + (XPUB, false, "pkh(xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs/0/*)"), + (YPUB, true, "sh(wpkh(xpub6CvvN4zrkrfXkSixaqSfG3dhqQEpd56sWLjzPjtk2sFEJHymSZXcFaC78fMYZ9cDrHVSRpCiQuV9yvgKw6CZF5PorLr5uQiSUStStZjpSSV/1/*))"), + (YPUB, false, "sh(wpkh(xpub6CvvN4zrkrfXkSixaqSfG3dhqQEpd56sWLjzPjtk2sFEJHymSZXcFaC78fMYZ9cDrHVSRpCiQuV9yvgKw6CZF5PorLr5uQiSUStStZjpSSV/0/*))"), + (ZPUB, true, "wpkh(xpub6CbPqb3FCEjaF4LnfMwdEAUxKhC6ZP1sJzGiMMz3mfmcjXdFPM9LB9S8HSChXW593am685964YZk8Hng1ekynqNWGRZfpo8PpDaUmyvQqvY/1/*)"), + (ZPUB, false, "wpkh(xpub6CbPqb3FCEjaF4LnfMwdEAUxKhC6ZP1sJzGiMMz3mfmcjXdFPM9LB9S8HSChXW593am685964YZk8Hng1ekynqNWGRZfpo8PpDaUmyvQqvY/0/*)"), + (TPUB, true, "pkh(tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5/1/*)"), + (TPUB, false, "pkh(tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5/0/*)"), + (UPUB, true, "sh(wpkh(tpubDCuv8pfb4pMsshrP2WhBqoV3PARvDPPz8rGUV1iWmz6LfNwNBDr5kgpMD6eaH8Y3rxJd9UHyzpDx8Yhj1eQrFoSCYqMc5nP4Nbi1VvJmNco/1/*))"), + (UPUB, false, "sh(wpkh(tpubDCuv8pfb4pMsshrP2WhBqoV3PARvDPPz8rGUV1iWmz6LfNwNBDr5kgpMD6eaH8Y3rxJd9UHyzpDx8Yhj1eQrFoSCYqMc5nP4Nbi1VvJmNco/0/*))"), + (VPUB, true , "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/1/*)"), + (VPUB, false, "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/0/*)"), + ]; + + for &(key, change, expect) in cases.iter() { + let result = generate_descriptor_from_xpub(key, change).unwrap(); + assert_eq!(result, expect); + } } } diff --git a/crates/floresta-node/src/wallet_input.rs b/crates/floresta-node/src/wallet_input.rs index 9ce5c9abd..a46db1a7a 100644 --- a/crates/floresta-node/src/wallet_input.rs +++ b/crates/floresta-node/src/wallet_input.rs @@ -9,24 +9,26 @@ use miniscript::DescriptorPublicKey; use tracing::error; use crate::error::FlorestadError; +use crate::slip132::generate_descriptor_from_xpub; +use crate::slip132::is_xpub_mainnet; -pub mod extended_pub_key { - use bitcoin::bip32::Xpub; - - use crate::slip132; - - pub fn from_str(s: &str) -> Result { - slip132::FromSlip132::from_slip132_str(s) - } -} - -fn parse_xpubs(xpubs: &[String]) -> Result>, FlorestadError> { +fn parse_xpubs( + xpubs: &[String], + network: Network, +) -> Result>, FlorestadError> { let mut descriptors = Vec::new(); for key in xpubs { + // Check if the xpub network matches the expected network + let is_mainnet = is_xpub_mainnet(key.as_str())?; + if (is_mainnet && network != Network::Bitcoin) + || (!is_mainnet && network == Network::Bitcoin) + { + return Err(FlorestadError::XpubNetworkMismatch(key.clone())); + } + // Parses the descriptor and get an external and change descriptors - let xpub = extended_pub_key::from_str(key.as_str())?; - let main_desc = format!("wpkh({xpub}/0/*)"); - let change_desc = format!("wpkh({xpub}/1/*)"); + let main_desc = generate_descriptor_from_xpub(key.as_str(), false)?; + let change_desc = generate_descriptor_from_xpub(key.as_str(), true)?; descriptors.push(Descriptor::::from_str(&main_desc)?); descriptors.push(Descriptor::::from_str(&change_desc)?); } @@ -47,7 +49,7 @@ impl InitialWalletSetup { network: Network, addresses_per_descriptor: u32, ) -> Result { - let mut descriptors = parse_xpubs(xpubs)?; + let mut descriptors = parse_xpubs(xpubs, network)?; descriptors.extend(parse_descriptors(initial_descriptors)?); descriptors.sort(); descriptors.dedup(); @@ -98,62 +100,141 @@ pub fn parse_descriptors( #[cfg(test)] pub mod test { - use bitcoin::bip32::ChildNumber; - use bitcoin::secp256k1::Secp256k1; use bitcoin::Network; use super::*; + const XPUB: &str = "xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs"; + const YPUB: &str = "ypub6XmBfjfmuYD1bjv5RCEHU8jD1NPGZh6NRTGDB8ndQsd7MPnzhDhAsdrF9sK8Z4G9FvcFBHoGsZqhsDHtenca3K5QigYWVKXvkAx6HBxVGYM"; + const ZPUB: &str = "zpub6rFvSvP5VbpXwej2L5WseLfxfdUzSczs9DK9v9mpXgXNqjFhtfUTRGkQKr7sXKNyrrzhd2LCysGqts1oT3b1PJji16xWzcmNMfhmZ8kkLZ1"; + const XPUB_MAINNET: [&str; 3] = [XPUB, YPUB, ZPUB]; + + const TPUB: &str = "tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5"; + const UPUB: &str = "upub5E3Vhaq9uVmz426B5FME1csAY8tvQ8vRqt7WnGyiJ4CoknpyM2WJk4B6uSh2kud3r8RJHTzS5jLFnWNRThKZyew6tDX2eXGMyTvfa8AVwyK"; + const VPUB: &str = "vpub5Zrsj9pYeJLwTfggbSQYZDdpEpZ4M1qB1EUKfXB9bjsookSNjM6c6eFTYfjb8KcGJV4ZqAYScBvC7hyDbbWKCHVcC6RETNJUfwUFvnHJM8Y"; + const XPUB_TESTNET: [&str; 3] = [TPUB, UPUB, VPUB]; + #[test] fn test_xpub_parsing() { - // Test cases from https://github.com/satoshilabs/slips/blob/master/slip-0132.md - const XPUB: &str = "xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj"; - const YPUB: &str = "ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"; - const ZPUB: &str = "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"; + // xpub | network | (main address, change address) + let cases = &[ + ( + XPUB, + Network::Bitcoin, + [ + "1JHazecJrjbxBMQgRcyV3JCQJwVbHBjH5t", + "1JbCXSeZHizJDQANsgtLBjo5y24JNMyGTB", + ], + ), + ( + YPUB, + Network::Bitcoin, + [ + "31sQy1RG4Y6sCtCpmXrtiJooqzBozRUTU6", + "33kzJbaR4EDzEoigsKuLata1svSqNGsdSo", + ], + ), + ( + ZPUB, + Network::Bitcoin, + [ + "bc1qz4ta3h4ga6hdqa090wfpr83asyz5z40t272wez", + "bc1qjeq39p3mpvmwqwkpaqe9hdjgfhfa8w5z87tnp4", + ], + ), + ( + TPUB, + Network::Testnet, + [ + "mhk8YjtyHigqGMiEGaf8cnNW9Game9exC6", + "mmuYagUFFQtAzw8Ts7afED6HFboCy4e8WR", + ], + ), + ( + UPUB, + Network::Testnet, + [ + "2NBfJvMZadWb8mwtV3F4FXTqAJs3pkYNdn8", + "2MznomgtTHMBvsMqPwwE3sSLzj6F8w3Mnyi", + ], + ), + ( + VPUB, + Network::Testnet, + [ + "tb1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfd6zxzqa", + "tb1qzplphjt68gs0lwvxrq70t9j9cva8ky7r7ucz2g", + ], + ), + ( + VPUB, + Network::Regtest, + [ + "bcrt1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfdctl0h5", + "bcrt1qzplphjt68gs0lwvxrq70t9j9cva8ky7ru4p0ap", + ], + ), + ]; - let secp = Secp256k1::new(); + for (descriptor, network, addresses) in cases { + let parsed = parse_xpubs(&[descriptor.to_string()], *network).unwrap(); + assert_eq!(parsed.len(), 2); - let xpub: bitcoin::bip32::Xpub = super::extended_pub_key::from_str(XPUB) - .expect("Parsing failed") - .ckd_pub(&secp, ChildNumber::Normal { index: 0 }) - .and_then(|key| key.ckd_pub(&secp, ChildNumber::Normal { index: 0 })) - .unwrap(); - let ypub = super::extended_pub_key::from_str(YPUB) - .expect("Parsing failed") - .ckd_pub(&secp, ChildNumber::Normal { index: 0 }) - .and_then(|key| key.ckd_pub(&secp, ChildNumber::Normal { index: 0 })) - .unwrap(); - let zpub = super::extended_pub_key::from_str(ZPUB) - .expect("Parsing failed") - .ckd_pub(&secp, ChildNumber::Normal { index: 0 }) - .and_then(|key| key.ckd_pub(&secp, ChildNumber::Normal { index: 0 })) - .unwrap(); - // Old p2pkh - assert_eq!( - Address::p2pkh(xpub.to_pub(), Network::Bitcoin) - .to_string() - .as_str(), - "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA" - ); - - // p2wpkh-p2pkh - let script = Address::p2wpkh(&ypub.to_pub(), Network::Bitcoin).script_pubkey(); + let main_desc = parsed[0].clone(); + let main_address = main_desc + .at_derivation_index(0) + .unwrap() + .address(*network) + .unwrap(); + assert_eq!(main_address.to_string(), addresses[0]); - assert_eq!( - Address::p2sh(&script, Network::Bitcoin) + let change_desc = parsed[1].clone(); + let change_address = change_desc + .at_derivation_index(0) .unwrap() - .to_string() - .as_str(), - "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf" - ); + .address(*network) + .unwrap(); + assert_eq!(change_address.to_string(), addresses[1]); + } + } - // p2wpkh - assert_eq!( - Address::p2wpkh(&zpub.to_pub(), Network::Bitcoin) - .to_string() - .as_str(), - "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" - ) + #[test] + fn test_parse_xpub_with_correct_network() { + fn check(xpubs: [&str; 3], network: Network) { + for xpub in xpubs { + let parsed = parse_xpubs(&[xpub.to_string()], network); + assert!(parsed.is_ok()); + } + } + + check(XPUB_MAINNET, Network::Bitcoin); + + check(XPUB_TESTNET, Network::Regtest); + check(XPUB_TESTNET, Network::Testnet); + check(XPUB_TESTNET, Network::Testnet4); + check(XPUB_TESTNET, Network::Signet); + } + + #[test] + fn test_parse_xpub_with_wrong_network() { + fn check(xpubs: [&str; 3], network: Network) { + for xpub in xpubs { + let parsed = parse_xpubs(&[xpub.to_string()], network); + let err = parsed.err().unwrap(); + if let FlorestadError::XpubNetworkMismatch(actual) = err { + assert_eq!(actual, xpub.to_string()); + } else { + panic!("Expected XpubNetworkMismatch error"); + } + } + } + + check(XPUB_MAINNET, Network::Regtest); + check(XPUB_MAINNET, Network::Testnet); + check(XPUB_MAINNET, Network::Testnet4); + check(XPUB_MAINNET, Network::Signet); + + check(XPUB_TESTNET, Network::Bitcoin); } #[test] diff --git a/justfile b/justfile index 9800ebf14..bc8134cdf 100644 --- a/justfile +++ b/justfile @@ -148,7 +148,7 @@ gen-manpages path="": # Run typos spell-check: @just check-command typos spell-check "cargo +nightly install typos-cli --locked" - typos + typos --config _typos.toml # Usage: # just install # installs both florestad and floresta-cli From 0c6dc2ab9eca93413a91570cc59bef54fa668daa Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:17:22 -0300 Subject: [PATCH 5/9] test(integration): add wallet configuration and flag tests - Added `wallet_conf.py` to test wallet loading via `config.toml`. This test verifies: - Descriptors and XPUBs are correctly loaded from the configuration file. - XPRIVs and descriptors with private keys are rejected. - Added `wallet_flag.py` to test wallet loading via command-line flags. This test performs the same checks as `wallet_conf.py`, but uses flags to pass wallet information during node initialization. --- tests/florestad/wallet_conf.py | 120 +++++++++++++++++++++++++++ tests/florestad/wallet_flag.py | 88 ++++++++++++++++++++ tests/test_framework/constants.py | 11 +++ tests/test_framework/rpc/floresta.py | 6 ++ tests/test_runner.py | 2 + 5 files changed, 227 insertions(+) create mode 100644 tests/florestad/wallet_conf.py create mode 100644 tests/florestad/wallet_flag.py diff --git a/tests/florestad/wallet_conf.py b/tests/florestad/wallet_conf.py new file mode 100644 index 000000000..d6d9c32b2 --- /dev/null +++ b/tests/florestad/wallet_conf.py @@ -0,0 +1,120 @@ +""" +Test the wallet configuration using configuration files for the Floresta node. +""" + +import os + +from test_framework import FlorestaTestFramework +from test_framework.node import Node, NodeType +from test_framework.constants import ( + WALLET_ADDRESS, + WALLET_DESCRIPTOR_EXTERNAL, + WALLET_DESCRIPTOR_INTERNAL, + WALLET_DESCRIPTOR_PRIV_EXTERNAL, + WALLET_DESCRIPTOR_PRIV_INTERNAL, + WALLET_XPUB_BIP_84, + WALLET_XPRIV, +) + +WALLET_CONFIG_ADDRESS = "\n".join( + [ + "[wallet]", + f'addresses = [ "{WALLET_ADDRESS}" ]', + ] +) +WALLET_CONFIG_XPUB = "\n".join( + [ + "[wallet]", + f'xpubs = [ "{WALLET_XPUB_BIP_84}" ]', + ] +) + +WALLET_CONFIG_DESCRIPTOR = "\n".join( + [ + "[wallet]", + f'descriptors = [ "{WALLET_DESCRIPTOR_EXTERNAL}", "{WALLET_DESCRIPTOR_INTERNAL}" ]', + ] +) + +WALLET_CONFIG_XPRIV = "\n".join( + [ + "[wallet]", + f'xpubs = [ "{WALLET_XPRIV}" ]', + ] +) + +WALLET_CONFIG_DESCRIPTOR_PRIV = "\n".join( + [ + "[wallet]", + f'descriptors = [ "{WALLET_DESCRIPTOR_PRIV_EXTERNAL}", "{WALLET_DESCRIPTOR_PRIV_INTERNAL}" ]', + ] +) + + +class WalletConfTest(FlorestaTestFramework): + """ + Test the wallet configuration using configuration files for the Floresta node. + + This class tests the behavior of different wallet configurations, ensuring + that the node handles them correctly. + """ + + def set_test_params(self): + """ + Set up five nodes with different wallet configurations. + """ + self.florestad_addr = self.create_floresta_node(WALLET_CONFIG_ADDRESS) + self.florestad_xpub = self.create_floresta_node(WALLET_CONFIG_XPUB) + self.florestad_desc = self.create_floresta_node(WALLET_CONFIG_DESCRIPTOR) + self.florestad_xpriv = self.create_floresta_node(WALLET_CONFIG_XPRIV) + self.florestad_desc_priv = self.create_floresta_node( + WALLET_CONFIG_DESCRIPTOR_PRIV + ) + + def create_floresta_node(self, config): + """ + Create Floresta nodes with the given configuration. + """ + floresta_node = self.add_node_default_args(variant=NodeType.FLORESTAD) + config_dir = os.path.join(floresta_node.daemon.data_dir, "config.toml") + with open(config_dir, "w") as f: + f.write(config) + floresta_node.set_extra_args([f"--config-file={config_dir}"]) + + return floresta_node + + def run_test(self): + """ + Run the test cases for each node configuration. + """ + self.run_node(self.florestad_addr) + self.run_node(self.florestad_xpub) + self.run_node(self.florestad_desc) + + self.log("Checking descriptors for each wallet(addr)") + descriptors = self.florestad_addr.rpc.list_descriptors() + self.assertEqual(len(descriptors), 0) + + self.log("Checking descriptors for each wallet(xpub)") + descriptors = self.florestad_xpub.rpc.list_descriptors() + self.assertEqual(len(descriptors), 2) + self.assertEqual(descriptors[0], WALLET_DESCRIPTOR_EXTERNAL) + self.assertEqual(descriptors[1], WALLET_DESCRIPTOR_INTERNAL) + + self.log("Checking descriptors for each wallet(descriptor)") + descriptors = self.florestad_desc.rpc.list_descriptors() + self.assertEqual(len(descriptors), 2) + self.assertEqual(descriptors[0], WALLET_DESCRIPTOR_EXTERNAL) + self.assertEqual(descriptors[1], WALLET_DESCRIPTOR_INTERNAL) + + self.log("Checking descriptors for each wallet(xpriv)") + with self.assertRaises(Exception): + self.run_node(self.florestad_xpriv) + + self.log("Checking descriptors for each wallet(descriptor with privkey)") + with self.assertRaises(Exception): + self.run_node(self.florestad_desc_priv) + + +if __name__ == "__main__": + WalletConfTest().main() diff --git a/tests/florestad/wallet_flag.py b/tests/florestad/wallet_flag.py new file mode 100644 index 000000000..1c5cbbf7b --- /dev/null +++ b/tests/florestad/wallet_flag.py @@ -0,0 +1,88 @@ +""" +Test the wallet configuration flags for the Floresta node. + +This script tests the behavior of the `--wallet-xpub` and `--wallet-descriptor` +flags, ensuring that the node handles them correctly. +""" + +import os + +from test_framework import FlorestaTestFramework +from test_framework.node import NodeType +from test_framework.constants import ( + WALLET_ADDRESS, + WALLET_DESCRIPTOR_EXTERNAL, + WALLET_DESCRIPTOR_INTERNAL, + WALLET_DESCRIPTOR_PRIV_EXTERNAL, + WALLET_DESCRIPTOR_PRIV_INTERNAL, + WALLET_XPUB_BIP_84, + WALLET_XPRIV, +) + + +class WalletFlagTest(FlorestaTestFramework): + """ + Test the wallet configuration flags for the Floresta node. + """ + + def set_test_params(self): + """ + Set up four nodes with different wallet configurations. + """ + self.florestad_xpub = self.add_node_extra_args( + variant=NodeType.FLORESTAD, + extra_args=[ + f"--wallet-xpub={WALLET_XPUB_BIP_84}", + ], + ) + self.florestad_desc = self.add_node_extra_args( + variant=NodeType.FLORESTAD, + extra_args=[ + f"--wallet-descriptor={WALLET_DESCRIPTOR_EXTERNAL}", + f"--wallet-descriptor={WALLET_DESCRIPTOR_INTERNAL}", + ], + ) + self.florestad_xpriv = self.add_node_extra_args( + variant=NodeType.FLORESTAD, + extra_args=[ + f"--wallet-xpub={WALLET_XPRIV}", + ], + ) + self.florestad_desc_priv = self.add_node_extra_args( + variant=NodeType.FLORESTAD, + extra_args=[ + f"--wallet-descriptor={WALLET_DESCRIPTOR_PRIV_EXTERNAL}", + f"--wallet-descriptor={WALLET_DESCRIPTOR_PRIV_INTERNAL}", + ], + ) + + def run_test(self): + """ + Run the test cases for each node configuration. + """ + self.run_node(self.florestad_xpub) + self.run_node(self.florestad_desc) + + self.log("Checking descriptors for each wallet(xpub)") + descriptors = self.florestad_xpub.rpc.list_descriptors() + self.assertEqual(len(descriptors), 2) + self.assertEqual(descriptors[0], WALLET_DESCRIPTOR_EXTERNAL) + self.assertEqual(descriptors[1], WALLET_DESCRIPTOR_INTERNAL) + + self.log("Checking descriptors for each wallet(descriptor)") + descriptors = self.florestad_desc.rpc.list_descriptors() + self.assertEqual(len(descriptors), 2) + self.assertEqual(descriptors[0], WALLET_DESCRIPTOR_EXTERNAL) + self.assertEqual(descriptors[1], WALLET_DESCRIPTOR_INTERNAL) + + self.log("Checking descriptors for each wallet(xpriv)") + with self.assertRaises(Exception): + self.run_node(self.florestad_xpriv) + + self.log("Checking descriptors for each wallet(descriptor with privkey)") + with self.assertRaises(Exception): + self.run_node(self.florestad_desc_priv) + + +if __name__ == "__main__": + WalletFlagTest().main() diff --git a/tests/test_framework/constants.py b/tests/test_framework/constants.py index 2b798b618..f026d7423 100644 --- a/tests/test_framework/constants.py +++ b/tests/test_framework/constants.py @@ -12,4 +12,15 @@ TEST_CHAIN = "regtest" FLORESTA_TEMP_DIR = os.getenv("FLORESTA_TEMP_DIR") +# Mnemonics = useless ritual arm slow mention dog force almost sudden pulp rude eager +# pylint: disable = line-too-long +WALLET_XPRIV = "tprv8hCwaWbnCTeqSXMmEgtYqC3tjCHQTKphfXBG5MfWgcA6pif3fAUqCuqwphSyXmVFhd8b5ep5krkRxF6YkuQfxSAhHMTGeRA8rKPzQd9BMre" +WALLET_DESCRIPTOR_PRIV_EXTERNAL = f"wpkh({WALLET_XPRIV}/0/*)#amzqvgzd" +WALLET_DESCRIPTOR_PRIV_INTERNAL = f"wpkh({WALLET_XPRIV}/1/*)#v08p3aj4" +# pylint: disable = line-too-long +WALLET_XPUB = "tpubDDtyive2LqLWKzPZ8LZ9Ebi1JDoLcf1cEpn3Mshp6sxVfCupHZJRPQTozp2EpTF76vJcyQBN7VP7CjUntEJxeADnuTMNTYKoSWNae8soVyv" +WALLET_DESCRIPTOR_EXTERNAL = f"wpkh({WALLET_XPUB}/0/*)#7h6kdtnk" +WALLET_DESCRIPTOR_INTERNAL = f"wpkh({WALLET_XPUB}/1/*)#0rlhs7rw" +# pylint: disable = line-too-long +WALLET_XPUB_BIP_84 = "vpub5ZrpbMUWLCJ6MbpU1RzocWBddAQnk2XYry9JSXrtzxSqoicei28CzqUhiN2HJ8z2VjY6rsUNf4qxjym43ydhAFQJ7BDDcC2bK6et6x9hc4D" WALLET_ADDRESS = "bcrt1q427ze5mrzqupzyfmqsx9gxh7xav538yk2j4cft" diff --git a/tests/test_framework/rpc/floresta.py b/tests/test_framework/rpc/floresta.py index b4ba4fc18..d67609223 100644 --- a/tests/test_framework/rpc/floresta.py +++ b/tests/test_framework/rpc/floresta.py @@ -32,3 +32,9 @@ def get_memoryinfo(self, mode: str): raise ValueError(f"Invalid getmemoryinfo mode: '{mode}'") return self.perform_request("getmemoryinfo", params=[mode]) + + def list_descriptors(self): + """ + List all loaded descriptors + """ + return self.perform_request("listdescriptors") diff --git a/tests/test_runner.py b/tests/test_runner.py index c4441539b..75d4d7455 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -71,6 +71,8 @@ ("example", "bitcoin"), ("example", "utreexod"), ("florestad", "node-info"), + ("florestad", "wallet_conf"), + ("florestad", "wallet_flag"), ] # Before running the tests, we check if the number of tests From fcb3c4f3527ee5142cdd5612540d5114814f6515 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:19:33 -0300 Subject: [PATCH 6/9] test(integration): add integration tests for `loaddescriptor` RPC method - Added tests to verify that the `loaddescriptor` RPC method can successfully load a descriptor. - Ensured that duplicate descriptors cannot be loaded. - Verified that descriptors containing private keys are rejected. --- tests/floresta-cli/loaddescriptor.py | 60 ++++++++++++++++++++++++++++ tests/test_framework/rpc/floresta.py | 6 +++ tests/test_runner.py | 1 + 3 files changed, 67 insertions(+) create mode 100644 tests/floresta-cli/loaddescriptor.py diff --git a/tests/floresta-cli/loaddescriptor.py b/tests/floresta-cli/loaddescriptor.py new file mode 100644 index 000000000..cde041677 --- /dev/null +++ b/tests/floresta-cli/loaddescriptor.py @@ -0,0 +1,60 @@ +""" +floresta_cli_loaddescriptor.py + +Functional test for the `loaddescriptor` CLI utility to interact with a Floresta node. +""" + +from requests.exceptions import HTTPError + +from test_framework import FlorestaTestFramework +from test_framework.node import NodeType +from test_framework.constants import ( + WALLET_DESCRIPTOR_EXTERNAL, + WALLET_DESCRIPTOR_PRIV_EXTERNAL, +) + + +class LoadDescriptorTest(FlorestaTestFramework): + """ + Test the `loaddescriptor` RPC command with a fresh node. + """ + + def set_test_params(self): + """ + Setup a single node + """ + self.florestad = self.add_node_default_args(variant=NodeType.FLORESTAD) + + def check_descriptors(self): + """ + Check the descriptors loaded in the node. + """ + self.log("Checking loaded descriptors...") + descriptors = self.florestad.rpc.list_descriptors() + self.assertEqual(len(descriptors), 1) + self.assertEqual(descriptors[0], WALLET_DESCRIPTOR_EXTERNAL) + + def run_test(self): + self.run_node(self.florestad) + + self.log("Loading external wallet descriptor...") + result = self.florestad.rpc.load_descriptor(WALLET_DESCRIPTOR_EXTERNAL) + self.assertTrue(result) + + self.check_descriptors() + + self.log("Loading private key wallet descriptor (should fail)...") + with self.assertRaises(HTTPError): + result = self.florestad.rpc.load_descriptor(WALLET_DESCRIPTOR_PRIV_EXTERNAL) + + self.check_descriptors() + + self.log("Loading external wallet descriptor again (should fail)...") + with self.assertRaises(HTTPError): + result = self.florestad.rpc.load_descriptor(WALLET_DESCRIPTOR_EXTERNAL) + + self.check_descriptors() + + +if __name__ == "__main__": + LoadDescriptorTest().main() diff --git a/tests/test_framework/rpc/floresta.py b/tests/test_framework/rpc/floresta.py index d67609223..3932f4003 100644 --- a/tests/test_framework/rpc/floresta.py +++ b/tests/test_framework/rpc/floresta.py @@ -38,3 +38,9 @@ def list_descriptors(self): List all loaded descriptors """ return self.perform_request("listdescriptors") + + def load_descriptor(self, descriptor: str) -> dict: + """ + Load a descriptor into the node performing + """ + return self.perform_request("loaddescriptor", params=[descriptor]) diff --git a/tests/test_runner.py b/tests/test_runner.py index 75d4d7455..9087567ba 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -73,6 +73,7 @@ ("florestad", "node-info"), ("florestad", "wallet_conf"), ("florestad", "wallet_flag"), + ("floresta-cli", "loaddescriptor"), ] # Before running the tests, we check if the number of tests From 5110de8a75aaa069186348035e13ced59c185627 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:21:05 -0300 Subject: [PATCH 7/9] test(integration): add integration tests for `listdescriptors` RPC method - Added tests to verify that the `listdescriptors` RPC method correctly displays the descriptors loaded in the node. - Ensured that the method returns all loaded descriptors in the expected format. --- tests/floresta-cli/listdescriptors.py | 41 +++++++++++++++++++++++++++ tests/test_runner.py | 1 + 2 files changed, 42 insertions(+) create mode 100644 tests/floresta-cli/listdescriptors.py diff --git a/tests/floresta-cli/listdescriptors.py b/tests/floresta-cli/listdescriptors.py new file mode 100644 index 000000000..64eb1e28b --- /dev/null +++ b/tests/floresta-cli/listdescriptors.py @@ -0,0 +1,41 @@ +""" +floresta_cli_listdescriptor.py + +This functional test cli utility to interact with a Floresta node with `listdescriptor` +""" + +from test_framework import FlorestaTestFramework +from test_framework.node import NodeType +from test_framework.constants import WALLET_DESCRIPTOR_EXTERNAL + + +class ListDescriptorTest(FlorestaTestFramework): + """ + Test the `listdescriptors` RPC command with a fresh node. + """ + + def set_test_params(self): + """ + Setup a single node + """ + self.florestad = self.add_node_default_args(variant=NodeType.FLORESTAD) + + def run_test(self): + self.run_node(self.florestad) + + self.log("Checking initial descriptors...") + descriptors = self.florestad.rpc.list_descriptors() + self.assertEqual(len(descriptors), 0) + + self.log("Loading external wallet descriptor...") + result = self.florestad.rpc.load_descriptor(WALLET_DESCRIPTOR_EXTERNAL) + self.assertTrue(result) + + self.log("Checking loaded descriptors...") + descriptors = self.florestad.rpc.list_descriptors() + self.assertEqual(len(descriptors), 1) + self.assertEqual(descriptors[0], WALLET_DESCRIPTOR_EXTERNAL) + + +if __name__ == "__main__": + ListDescriptorTest().main() diff --git a/tests/test_runner.py b/tests/test_runner.py index 9087567ba..9a641ca0a 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -74,6 +74,7 @@ ("florestad", "wallet_conf"), ("florestad", "wallet_flag"), ("floresta-cli", "loaddescriptor"), + ("floresta-cli", "listdescriptors"), ] # Before running the tests, we check if the number of tests From 4c883d52d5423942dde77a8cb0df3b13842e1816 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:49:47 -0300 Subject: [PATCH 8/9] chore(wallet): centralize descriptor parsing logic in `floresta-watch-only` module Improved code organization by consolidating descriptor handling in a single module - Moved `parse_descriptors`, `parse_xpubs`, and `slip132` to the `floresta-watch-only` module. - Centralized descriptor and XPUB parsing logic, previously scattered across `floresta-node` and `floresta-common`. - Removed descriptor-related features and the `miniscript` dependency from `floresta-common`. --- .github/workflows/rust.yml | 2 - Cargo.lock | 2 +- crates/floresta-common/Cargo.toml | 6 +- crates/floresta-common/src/lib.rs | 59 +++-- crates/floresta-node/src/error.rs | 18 +- crates/floresta-node/src/json_rpc/server.rs | 2 +- crates/floresta-node/src/lib.rs | 1 - crates/floresta-node/src/wallet_input.rs | 209 +--------------- crates/floresta-watch-only/Cargo.toml | 3 +- .../floresta-watch-only/src/descriptor/mod.rs | 234 ++++++++++++++++++ .../src/descriptor}/slip132.rs | 0 crates/floresta-watch-only/src/lib.rs | 4 +- 12 files changed, 288 insertions(+), 252 deletions(-) create mode 100644 crates/floresta-watch-only/src/descriptor/mod.rs rename crates/{floresta-node/src => floresta-watch-only/src/descriptor}/slip132.rs (100%) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 418cd16d8..c130ee1a2 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -142,8 +142,6 @@ jobs: include: - crate: floresta-common feature_args: "" - - crate: floresta-common - feature_args: "--features descriptors-no-std" steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 083584cfd..8d7843cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -964,7 +964,6 @@ version = "0.4.0" dependencies = [ "bitcoin", "hashbrown", - "miniscript", "sha2", "spin", ] @@ -1078,6 +1077,7 @@ dependencies = [ "floresta-chain", "floresta-common", "kv", + "miniscript", "rand", "serde", "serde_json", diff --git a/crates/floresta-common/Cargo.toml b/crates/floresta-common/Cargo.toml index c8119aad2..2440e05e0 100644 --- a/crates/floresta-common/Cargo.toml +++ b/crates/floresta-common/Cargo.toml @@ -11,16 +11,12 @@ readme.workspace = true # Floresta/README.md [dependencies] bitcoin = { workspace = true } hashbrown = { version = "0.16" } -miniscript = { workspace = true, optional = true, default-features = false } sha2 = { workspace = true } spin = { workspace = true } [features] -default = ["std", "descriptors-std"] +default = ["std"] std = ["bitcoin/std", "sha2/std"] -# Both features can be set at the same time, but for `no_std` only the latter should be used -descriptors-std = ["miniscript/std"] -descriptors-no-std = ["miniscript/no-std"] [lints] workspace = true diff --git a/crates/floresta-common/src/lib.rs b/crates/floresta-common/src/lib.rs index b10e4142a..f31b91b45 100644 --- a/crates/floresta-common/src/lib.rs +++ b/crates/floresta-common/src/lib.rs @@ -18,10 +18,6 @@ use bitcoin::hashes::sha256; use bitcoin::hashes::Hash; use bitcoin::ScriptBuf; use bitcoin::VarInt; -#[cfg(any(feature = "descriptors-std", feature = "descriptors-no-std"))] -use miniscript::Descriptor; -#[cfg(any(feature = "descriptors-std", feature = "descriptors-no-std"))] -use miniscript::DescriptorPublicKey; use sha2::Digest; #[cfg(feature = "std")] @@ -33,8 +29,6 @@ pub mod spsc; #[cfg(feature = "std")] pub use ema::Ema; -#[cfg(any(feature = "descriptors-std", feature = "descriptors-no-std"))] -use prelude::*; pub use spsc::Channel; /// Computes the SHA-256 digest of the byte slice data and returns a [Hash] from `bitcoin_hashes`. @@ -86,24 +80,41 @@ pub mod service_flags { pub const UTREEXO_FILTER: u64 = 1 << 25; } -#[cfg(any(feature = "descriptors-std", feature = "descriptors-no-std"))] -/// Takes an array of descriptors as `String`, performs sanity checks on each one -/// and returns list of parsed descriptors. -pub fn parse_descriptors( - descriptors: &[String], -) -> Result>, miniscript::Error> { - let descriptors = descriptors - .iter() - .map(|descriptor| { - let descriptor = Descriptor::::from_str(descriptor.as_str())?; - descriptor.sanity_check()?; - descriptor.into_single_descriptors() - }) - .collect::>, _>>()? - .into_iter() - .flatten() - .collect::>(); - Ok(descriptors) +#[derive(Debug, Clone)] +/// A simple fraction struct that allows adding numbers to the numerator and denominator +/// +/// If we want compute a rolling-average, we would naively hold all elements in a list and +/// compute the average from it. This is not efficient, as it requires O(n) memory and O(n) +/// time to compute the average. Instead, we can use a fraction to compute the average in O(1) +/// time and O(1) memory, by keeping track of the sum of all elements and the number of elements. +pub struct FractionAvg { + numerator: u64, + denominator: u64, +} + +impl FractionAvg { + /// Creates a new fraction with the given numerator and denominator + pub fn new(numerator: u64, denominator: u64) -> Self { + Self { + numerator, + denominator, + } + } + + /// Adds a number to the numerator and increments the denominator + pub fn add(&mut self, other: u64) { + self.numerator += other; + self.denominator += 1; + } + + /// Returns the average of the fraction + pub fn value(&self) -> f64 { + if self.denominator == 0 { + return 0.0; + } + + self.numerator as f64 / self.denominator as f64 + } } #[cfg(not(feature = "std"))] diff --git a/crates/floresta-node/src/error.rs b/crates/floresta-node/src/error.rs index 9003c4d69..a2a2e3cf2 100644 --- a/crates/floresta-node/src/error.rs +++ b/crates/floresta-node/src/error.rs @@ -6,11 +6,11 @@ use floresta_chain::BlockchainError; use floresta_chain::FlatChainstoreError; #[cfg(feature = "compact-filters")] use floresta_compact_filters::IterableFilterStoreError; +use floresta_watch_only::descriptor::DescriptorError; use floresta_watch_only::kv_database::KvDatabaseError; use floresta_watch_only::WatchOnlyError; use tokio_rustls::rustls::pki_types; -use crate::slip132; #[derive(Debug)] pub enum FlorestadError { /// Encoding/decoding error. @@ -41,7 +41,7 @@ pub enum FlorestadError { TomlParsing(toml::de::Error), /// Parsing registered HD version bytes from slip132. - WalletInput(slip132::Error), + WalletInput(DescriptorError), /// Parsing a bitcoin address. AddressParsing(bitcoin::address::ParseError), @@ -98,9 +98,6 @@ pub enum FlorestadError { /// Failed to create the TLS data directory. CouldNotCreateTLSDataDir(String, std::io::Error), - /// Failed to provide a valid xpub. - InvalidProvidedXpub(String, slip132::Error), - /// Failed to obtain the wallet cache. CouldNotObtainWalletCache(WatchOnlyError), @@ -124,9 +121,6 @@ pub enum FlorestadError { /// Load a flat chain store error. CouldNotLoadFlatChainStore(BlockchainError), - - /// Xpub network mismatch error. - XpubNetworkMismatch(String), } impl std::fmt::Display for FlorestadError { @@ -201,9 +195,6 @@ impl std::fmt::Display for FlorestadError { FlorestadError::CouldNotCreateTLSDataDir(path, err) => { write!(f, "Could not create TLS data directory {path}: {err}") } - FlorestadError::InvalidProvidedXpub(xpub, err) => { - write!(f, "Invalid provided xpub {xpub}: {err:?}") - } FlorestadError::CouldNotObtainWalletCache(err) => { write!(f, "Could not obtain wallet cache: {err}") } @@ -228,9 +219,6 @@ impl std::fmt::Display for FlorestadError { FlorestadError::CouldNotLoadFlatChainStore(err) => { write!(f, "Failure while loading flat chainstore: {err:?}") } - FlorestadError::XpubNetworkMismatch(xpub) => { - write!(f, "Xpub network mismatch: {xpub}") - } } } } @@ -254,7 +242,7 @@ impl_from_error!(Io, std::io::Error); impl_from_error!(ScriptValidation, bitcoin::blockdata::script::Error); impl_from_error!(Blockchain, BlockchainError); impl_from_error!(SerdeJson, serde_json::Error); -impl_from_error!(WalletInput, slip132::Error); +impl_from_error!(WalletInput, DescriptorError); impl_from_error!(TomlParsing, toml::de::Error); impl_from_error!(BlockValidation, BlockValidationErrors); impl_from_error!(AddressParsing, bitcoin::address::ParseError); diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 861e2d064..5308823ae 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -23,9 +23,9 @@ use bitcoin::TxIn; use bitcoin::TxOut; use bitcoin::Txid; use floresta_chain::ThreadSafeChain; -use floresta_common::parse_descriptors; use floresta_compact_filters::flat_filters_store::FlatFiltersStore; use floresta_compact_filters::network_filters::NetworkFilters; +use floresta_watch_only::descriptor::parse_descriptors; use floresta_watch_only::kv_database::KvDatabase; use floresta_watch_only::AddressCache; use floresta_watch_only::CachedTransaction; diff --git a/crates/floresta-node/src/lib.rs b/crates/floresta-node/src/lib.rs index d4366d021..83365d6ea 100644 --- a/crates/floresta-node/src/lib.rs +++ b/crates/floresta-node/src/lib.rs @@ -12,7 +12,6 @@ mod error; mod florestad; #[cfg(feature = "json-rpc")] mod json_rpc; -mod slip132; mod wallet_input; #[cfg(feature = "zmq-server")] mod zmq; diff --git a/crates/floresta-node/src/wallet_input.rs b/crates/floresta-node/src/wallet_input.rs index a46db1a7a..990ba52c0 100644 --- a/crates/floresta-node/src/wallet_input.rs +++ b/crates/floresta-node/src/wallet_input.rs @@ -4,36 +4,13 @@ use std::str::FromStr; use bitcoin::Address; use bitcoin::Network; +use floresta_watch_only::descriptor::parse_descriptors; +use floresta_watch_only::descriptor::parse_xpubs; use miniscript::Descriptor; use miniscript::DescriptorPublicKey; use tracing::error; use crate::error::FlorestadError; -use crate::slip132::generate_descriptor_from_xpub; -use crate::slip132::is_xpub_mainnet; - -fn parse_xpubs( - xpubs: &[String], - network: Network, -) -> Result>, FlorestadError> { - let mut descriptors = Vec::new(); - for key in xpubs { - // Check if the xpub network matches the expected network - let is_mainnet = is_xpub_mainnet(key.as_str())?; - if (is_mainnet && network != Network::Bitcoin) - || (!is_mainnet && network == Network::Bitcoin) - { - return Err(FlorestadError::XpubNetworkMismatch(key.clone())); - } - - // Parses the descriptor and get an external and change descriptors - let main_desc = generate_descriptor_from_xpub(key.as_str(), false)?; - let change_desc = generate_descriptor_from_xpub(key.as_str(), true)?; - descriptors.push(Descriptor::::from_str(&main_desc)?); - descriptors.push(Descriptor::::from_str(&change_desc)?); - } - Ok(descriptors) -} #[derive(Debug, Clone, PartialEq)] pub(crate) struct InitialWalletSetup { @@ -50,9 +27,12 @@ impl InitialWalletSetup { addresses_per_descriptor: u32, ) -> Result { let mut descriptors = parse_xpubs(xpubs, network)?; + descriptors.extend(parse_descriptors(initial_descriptors)?); + descriptors.sort(); descriptors.dedup(); + let mut addresses = addresses .iter() .flat_map(|address| match Address::from_str(address) { @@ -63,6 +43,7 @@ impl InitialWalletSetup { } }) .collect::, _>>()?; + addresses.extend(descriptors.iter().flat_map(|descriptor| { (0..addresses_per_descriptor).map(|index| { descriptor @@ -72,8 +53,10 @@ impl InitialWalletSetup { .expect("Error while deriving address. Is this an active descriptor?") }) })); + addresses.sort(); addresses.dedup(); + Ok(Self { descriptors, addresses, @@ -81,186 +64,10 @@ impl InitialWalletSetup { } } -pub fn parse_descriptors( - descriptors: &[String], -) -> Result>, FlorestadError> { - let descriptors = descriptors - .iter() - .map(|descriptor| { - let descriptor = Descriptor::::from_str(descriptor.as_str())?; - descriptor.sanity_check()?; - descriptor.into_single_descriptors() - }) - .collect::>, _>>()? - .into_iter() - .flatten() - .collect::>(); - Ok(descriptors) -} - #[cfg(test)] pub mod test { - use bitcoin::Network; - use super::*; - const XPUB: &str = "xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs"; - const YPUB: &str = "ypub6XmBfjfmuYD1bjv5RCEHU8jD1NPGZh6NRTGDB8ndQsd7MPnzhDhAsdrF9sK8Z4G9FvcFBHoGsZqhsDHtenca3K5QigYWVKXvkAx6HBxVGYM"; - const ZPUB: &str = "zpub6rFvSvP5VbpXwej2L5WseLfxfdUzSczs9DK9v9mpXgXNqjFhtfUTRGkQKr7sXKNyrrzhd2LCysGqts1oT3b1PJji16xWzcmNMfhmZ8kkLZ1"; - const XPUB_MAINNET: [&str; 3] = [XPUB, YPUB, ZPUB]; - - const TPUB: &str = "tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5"; - const UPUB: &str = "upub5E3Vhaq9uVmz426B5FME1csAY8tvQ8vRqt7WnGyiJ4CoknpyM2WJk4B6uSh2kud3r8RJHTzS5jLFnWNRThKZyew6tDX2eXGMyTvfa8AVwyK"; - const VPUB: &str = "vpub5Zrsj9pYeJLwTfggbSQYZDdpEpZ4M1qB1EUKfXB9bjsookSNjM6c6eFTYfjb8KcGJV4ZqAYScBvC7hyDbbWKCHVcC6RETNJUfwUFvnHJM8Y"; - const XPUB_TESTNET: [&str; 3] = [TPUB, UPUB, VPUB]; - - #[test] - fn test_xpub_parsing() { - // xpub | network | (main address, change address) - let cases = &[ - ( - XPUB, - Network::Bitcoin, - [ - "1JHazecJrjbxBMQgRcyV3JCQJwVbHBjH5t", - "1JbCXSeZHizJDQANsgtLBjo5y24JNMyGTB", - ], - ), - ( - YPUB, - Network::Bitcoin, - [ - "31sQy1RG4Y6sCtCpmXrtiJooqzBozRUTU6", - "33kzJbaR4EDzEoigsKuLata1svSqNGsdSo", - ], - ), - ( - ZPUB, - Network::Bitcoin, - [ - "bc1qz4ta3h4ga6hdqa090wfpr83asyz5z40t272wez", - "bc1qjeq39p3mpvmwqwkpaqe9hdjgfhfa8w5z87tnp4", - ], - ), - ( - TPUB, - Network::Testnet, - [ - "mhk8YjtyHigqGMiEGaf8cnNW9Game9exC6", - "mmuYagUFFQtAzw8Ts7afED6HFboCy4e8WR", - ], - ), - ( - UPUB, - Network::Testnet, - [ - "2NBfJvMZadWb8mwtV3F4FXTqAJs3pkYNdn8", - "2MznomgtTHMBvsMqPwwE3sSLzj6F8w3Mnyi", - ], - ), - ( - VPUB, - Network::Testnet, - [ - "tb1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfd6zxzqa", - "tb1qzplphjt68gs0lwvxrq70t9j9cva8ky7r7ucz2g", - ], - ), - ( - VPUB, - Network::Regtest, - [ - "bcrt1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfdctl0h5", - "bcrt1qzplphjt68gs0lwvxrq70t9j9cva8ky7ru4p0ap", - ], - ), - ]; - - for (descriptor, network, addresses) in cases { - let parsed = parse_xpubs(&[descriptor.to_string()], *network).unwrap(); - assert_eq!(parsed.len(), 2); - - let main_desc = parsed[0].clone(); - let main_address = main_desc - .at_derivation_index(0) - .unwrap() - .address(*network) - .unwrap(); - assert_eq!(main_address.to_string(), addresses[0]); - - let change_desc = parsed[1].clone(); - let change_address = change_desc - .at_derivation_index(0) - .unwrap() - .address(*network) - .unwrap(); - assert_eq!(change_address.to_string(), addresses[1]); - } - } - - #[test] - fn test_parse_xpub_with_correct_network() { - fn check(xpubs: [&str; 3], network: Network) { - for xpub in xpubs { - let parsed = parse_xpubs(&[xpub.to_string()], network); - assert!(parsed.is_ok()); - } - } - - check(XPUB_MAINNET, Network::Bitcoin); - - check(XPUB_TESTNET, Network::Regtest); - check(XPUB_TESTNET, Network::Testnet); - check(XPUB_TESTNET, Network::Testnet4); - check(XPUB_TESTNET, Network::Signet); - } - - #[test] - fn test_parse_xpub_with_wrong_network() { - fn check(xpubs: [&str; 3], network: Network) { - for xpub in xpubs { - let parsed = parse_xpubs(&[xpub.to_string()], network); - let err = parsed.err().unwrap(); - if let FlorestadError::XpubNetworkMismatch(actual) = err { - assert_eq!(actual, xpub.to_string()); - } else { - panic!("Expected XpubNetworkMismatch error"); - } - } - } - - check(XPUB_MAINNET, Network::Regtest); - check(XPUB_MAINNET, Network::Testnet); - check(XPUB_MAINNET, Network::Testnet4); - check(XPUB_MAINNET, Network::Signet); - - check(XPUB_TESTNET, Network::Bitcoin); - } - - #[test] - fn test_descriptor_parsing() { - // singlesig - assert_eq!( - parse_descriptors(&[ - "wpkh([a5b13c0e/84h/0h/0h]xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/<0;1>/*)#n8sgapuv".to_owned() - ]).unwrap(), - parse_descriptors(&[ - "wpkh([a5b13c0e/84'/0'/0']xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/0/*)#wg8dh3s7".to_owned(), - "wpkh([a5b13c0e/84'/0'/0']xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/1/*)#luzv2yqx".to_owned() - ]).unwrap() - ); - // multisig - assert_eq!( - parse_descriptors(&[ - "wsh(sortedmulti(1,[6f826a6a/48h/0h/0h/2h]xpub6DsY48BAsvEMTRPbeSTu9jZXqEsTKr5T86WbRbXHp2gEVCNR3hALnMorFawVwnnHMMfjbyY8We9B4beh1fxqhcv6kgSeLgQxeXDqv3DaW7m/<0;1>/*,[a5b13c0e/48h/0h/0h/2h]xpub6Eqj1Hj3RezebC6cKiYYN2sAc1Wu33BWoaafnNgAbQwDkJdy7aXCYCmaMzb8rCpmh919UsehyV5Ywjo62hG4R2G2PGv4uqEDTUhYQw26BDJ/<0;1>/*))#nykmcu2v".to_owned() - ]).unwrap(), - parse_descriptors(&[ - "wsh(sortedmulti(1,[6f826a6a/48'/0'/0'/2']xpub6DsY48BAsvEMTRPbeSTu9jZXqEsTKr5T86WbRbXHp2gEVCNR3hALnMorFawVwnnHMMfjbyY8We9B4beh1fxqhcv6kgSeLgQxeXDqv3DaW7m/0/*,[a5b13c0e/48'/0'/0'/2']xpub6Eqj1Hj3RezebC6cKiYYN2sAc1Wu33BWoaafnNgAbQwDkJdy7aXCYCmaMzb8rCpmh919UsehyV5Ywjo62hG4R2G2PGv4uqEDTUhYQw26BDJ/0/*))#sw68w95x".to_owned(), - "wsh(sortedmulti(1,[6f826a6a/48'/0'/0'/2']xpub6DsY48BAsvEMTRPbeSTu9jZXqEsTKr5T86WbRbXHp2gEVCNR3hALnMorFawVwnnHMMfjbyY8We9B4beh1fxqhcv6kgSeLgQxeXDqv3DaW7m/1/*,[a5b13c0e/48'/0'/0'/2']xpub6Eqj1Hj3RezebC6cKiYYN2sAc1Wu33BWoaafnNgAbQwDkJdy7aXCYCmaMzb8rCpmh919UsehyV5Ywjo62hG4R2G2PGv4uqEDTUhYQw26BDJ/1/*))#fafrqkpn".to_owned() - ]).unwrap() - ); - } - #[test] fn test_initial_wallet_build() { use pretty_assertions::assert_eq; diff --git a/crates/floresta-watch-only/Cargo.toml b/crates/floresta-watch-only/Cargo.toml index b25bf4511..ed70177b1 100644 --- a/crates/floresta-watch-only/Cargo.toml +++ b/crates/floresta-watch-only/Cargo.toml @@ -16,10 +16,11 @@ kv = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } tracing = { workspace = true } +miniscript = { workspace = true, features = ["compiler"] } # Local dependencies floresta-chain = { workspace = true } -floresta-common = { workspace = true, features = ["descriptors-no-std"] } +floresta-common = { workspace = true } [dev-dependencies] rand = { workspace = true } diff --git a/crates/floresta-watch-only/src/descriptor/mod.rs b/crates/floresta-watch-only/src/descriptor/mod.rs new file mode 100644 index 000000000..89c8840f1 --- /dev/null +++ b/crates/floresta-watch-only/src/descriptor/mod.rs @@ -0,0 +1,234 @@ +use std::str::FromStr; + +use bitcoin::Network; +use floresta_common::impl_error_from; +use miniscript::Descriptor; +use miniscript::DescriptorPublicKey; +use miniscript::Error as MiniscriptError; + +mod slip132; + +use crate::descriptor::slip132::generate_descriptor_from_xpub; +use crate::descriptor::slip132::is_xpub_mainnet; +use crate::descriptor::slip132::Error as Slip132Error; + +#[derive(Debug)] +pub enum DescriptorError { + /// Error parsing xpub + XpubParseError(Slip132Error), + + /// Error xpub network mismatch + XpubNetworkMismatch(String), + + /// Error in miniscript + MiniscriptError(MiniscriptError), +} + +impl_error_from!(DescriptorError, Slip132Error, XpubParseError); +impl_error_from!(DescriptorError, MiniscriptError, MiniscriptError); + +pub fn parse_xpubs( + xpubs: &[String], + network: Network, +) -> Result>, DescriptorError> { + let mut descriptors = Vec::new(); + for key in xpubs { + // Check if the xpub network matches the expected network + let is_mainnet = is_xpub_mainnet(key.as_str())?; + if (is_mainnet && network != Network::Bitcoin) + || (!is_mainnet && network == Network::Bitcoin) + { + return Err(DescriptorError::XpubNetworkMismatch(key.clone())); + } + + // Parses the descriptor and get an external and change descriptors + let main_desc = generate_descriptor_from_xpub(key.as_str(), false)?; + let change_desc = generate_descriptor_from_xpub(key.as_str(), true)?; + descriptors.push(Descriptor::::from_str(&main_desc)?); + descriptors.push(Descriptor::::from_str(&change_desc)?); + } + Ok(descriptors) +} + +/// Takes an array of descriptors as `String`, performs sanity checks on each one +/// and returns list of parsed descriptors. +pub fn parse_descriptors( + descriptors: &[String], +) -> Result>, MiniscriptError> { + let descriptors = descriptors + .iter() + .map(|descriptor| { + let descriptor = Descriptor::::from_str(descriptor.as_str())?; + descriptor.sanity_check()?; + descriptor.into_single_descriptors() + }) + .collect::>, _>>()? + .into_iter() + .flatten() + .collect::>(); + Ok(descriptors) +} + +#[cfg(test)] +mod test { + use bitcoin::Network; + + use super::*; + + const XPUB: &str = "xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs"; + const YPUB: &str = "ypub6XmBfjfmuYD1bjv5RCEHU8jD1NPGZh6NRTGDB8ndQsd7MPnzhDhAsdrF9sK8Z4G9FvcFBHoGsZqhsDHtenca3K5QigYWVKXvkAx6HBxVGYM"; + const ZPUB: &str = "zpub6rFvSvP5VbpXwej2L5WseLfxfdUzSczs9DK9v9mpXgXNqjFhtfUTRGkQKr7sXKNyrrzhd2LCysGqts1oT3b1PJji16xWzcmNMfhmZ8kkLZ1"; + const XPUB_MAINNET: [&str; 3] = [XPUB, YPUB, ZPUB]; + + const TPUB: &str = "tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5"; + const UPUB: &str = "upub5E3Vhaq9uVmz426B5FME1csAY8tvQ8vRqt7WnGyiJ4CoknpyM2WJk4B6uSh2kud3r8RJHTzS5jLFnWNRThKZyew6tDX2eXGMyTvfa8AVwyK"; + const VPUB: &str = "vpub5Zrsj9pYeJLwTfggbSQYZDdpEpZ4M1qB1EUKfXB9bjsookSNjM6c6eFTYfjb8KcGJV4ZqAYScBvC7hyDbbWKCHVcC6RETNJUfwUFvnHJM8Y"; + const XPUB_TESTNET: [&str; 3] = [TPUB, UPUB, VPUB]; + + #[test] + fn test_xpub_parsing() { + // xpub | network | (main address, change address) + let cases = &[ + ( + XPUB, + Network::Bitcoin, + [ + "1JHazecJrjbxBMQgRcyV3JCQJwVbHBjH5t", + "1JbCXSeZHizJDQANsgtLBjo5y24JNMyGTB", + ], + ), + ( + YPUB, + Network::Bitcoin, + [ + "31sQy1RG4Y6sCtCpmXrtiJooqzBozRUTU6", + "33kzJbaR4EDzEoigsKuLata1svSqNGsdSo", + ], + ), + ( + ZPUB, + Network::Bitcoin, + [ + "bc1qz4ta3h4ga6hdqa090wfpr83asyz5z40t272wez", + "bc1qjeq39p3mpvmwqwkpaqe9hdjgfhfa8w5z87tnp4", + ], + ), + ( + TPUB, + Network::Testnet, + [ + "mhk8YjtyHigqGMiEGaf8cnNW9Game9exC6", + "mmuYagUFFQtAzw8Ts7afED6HFboCy4e8WR", + ], + ), + ( + UPUB, + Network::Testnet, + [ + "2NBfJvMZadWb8mwtV3F4FXTqAJs3pkYNdn8", + "2MznomgtTHMBvsMqPwwE3sSLzj6F8w3Mnyi", + ], + ), + ( + VPUB, + Network::Testnet, + [ + "tb1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfd6zxzqa", + "tb1qzplphjt68gs0lwvxrq70t9j9cva8ky7r7ucz2g", + ], + ), + ( + VPUB, + Network::Regtest, + [ + "bcrt1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfdctl0h5", + "bcrt1qzplphjt68gs0lwvxrq70t9j9cva8ky7ru4p0ap", + ], + ), + ]; + + for (descriptor, network, addresses) in cases { + let parsed = parse_xpubs(&[descriptor.to_string()], *network).unwrap(); + assert_eq!(parsed.len(), 2); + + let main_desc = parsed[0].clone(); + let main_address = main_desc + .at_derivation_index(0) + .unwrap() + .address(*network) + .unwrap(); + assert_eq!(main_address.to_string(), addresses[0]); + + let change_desc = parsed[1].clone(); + let change_address = change_desc + .at_derivation_index(0) + .unwrap() + .address(*network) + .unwrap(); + assert_eq!(change_address.to_string(), addresses[1]); + } + } + + #[test] + fn test_parse_xpub_with_correct_network() { + fn check(xpubs: [&str; 3], network: Network) { + for xpub in xpubs { + let parsed = parse_xpubs(&[xpub.to_string()], network); + assert!(parsed.is_ok()); + } + } + + check(XPUB_MAINNET, Network::Bitcoin); + + check(XPUB_TESTNET, Network::Regtest); + check(XPUB_TESTNET, Network::Testnet); + check(XPUB_TESTNET, Network::Testnet4); + check(XPUB_TESTNET, Network::Signet); + } + + #[test] + fn test_parse_xpub_with_wrong_network() { + fn check(xpubs: [&str; 3], network: Network) { + for xpub in xpubs { + let parsed = parse_xpubs(&[xpub.to_string()], network); + let err = parsed.err().unwrap(); + if let DescriptorError::XpubNetworkMismatch(actual) = err { + assert_eq!(actual, xpub.to_string()); + } else { + panic!("Expected XpubNetworkMismatch error"); + } + } + } + + check(XPUB_MAINNET, Network::Regtest); + check(XPUB_MAINNET, Network::Testnet); + check(XPUB_MAINNET, Network::Testnet4); + check(XPUB_MAINNET, Network::Signet); + + check(XPUB_TESTNET, Network::Bitcoin); + } + + #[test] + fn test_descriptor_parsing() { + // singlesig + assert_eq!( + parse_descriptors(&[ + "wpkh([a5b13c0e/84h/0h/0h]xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/<0;1>/*)#n8sgapuv".to_owned() + ]).unwrap(), + parse_descriptors(&[ + "wpkh([a5b13c0e/84'/0'/0']xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/0/*)#wg8dh3s7".to_owned(), + "wpkh([a5b13c0e/84'/0'/0']xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/1/*)#luzv2yqx".to_owned() + ]).unwrap() + ); + // multisig + assert_eq!( + parse_descriptors(&[ + "wsh(sortedmulti(1,[6f826a6a/48h/0h/0h/2h]xpub6DsY48BAsvEMTRPbeSTu9jZXqEsTKr5T86WbRbXHp2gEVCNR3hALnMorFawVwnnHMMfjbyY8We9B4beh1fxqhcv6kgSeLgQxeXDqv3DaW7m/<0;1>/*,[a5b13c0e/48h/0h/0h/2h]xpub6Eqj1Hj3RezebC6cKiYYN2sAc1Wu33BWoaafnNgAbQwDkJdy7aXCYCmaMzb8rCpmh919UsehyV5Ywjo62hG4R2G2PGv4uqEDTUhYQw26BDJ/<0;1>/*))#nykmcu2v".to_owned() + ]).unwrap(), + parse_descriptors(&[ + "wsh(sortedmulti(1,[6f826a6a/48'/0'/0'/2']xpub6DsY48BAsvEMTRPbeSTu9jZXqEsTKr5T86WbRbXHp2gEVCNR3hALnMorFawVwnnHMMfjbyY8We9B4beh1fxqhcv6kgSeLgQxeXDqv3DaW7m/0/*,[a5b13c0e/48'/0'/0'/2']xpub6Eqj1Hj3RezebC6cKiYYN2sAc1Wu33BWoaafnNgAbQwDkJdy7aXCYCmaMzb8rCpmh919UsehyV5Ywjo62hG4R2G2PGv4uqEDTUhYQw26BDJ/0/*))#sw68w95x".to_owned(), + "wsh(sortedmulti(1,[6f826a6a/48'/0'/0'/2']xpub6DsY48BAsvEMTRPbeSTu9jZXqEsTKr5T86WbRbXHp2gEVCNR3hALnMorFawVwnnHMMfjbyY8We9B4beh1fxqhcv6kgSeLgQxeXDqv3DaW7m/1/*,[a5b13c0e/48'/0'/0'/2']xpub6Eqj1Hj3RezebC6cKiYYN2sAc1Wu33BWoaafnNgAbQwDkJdy7aXCYCmaMzb8rCpmh919UsehyV5Ywjo62hG4R2G2PGv4uqEDTUhYQw26BDJ/1/*))#fafrqkpn".to_owned() + ]).unwrap() + ); + } +} diff --git a/crates/floresta-node/src/slip132.rs b/crates/floresta-watch-only/src/descriptor/slip132.rs similarity index 100% rename from crates/floresta-node/src/slip132.rs rename to crates/floresta-watch-only/src/descriptor/slip132.rs diff --git a/crates/floresta-watch-only/src/lib.rs b/crates/floresta-watch-only/src/lib.rs index 7c0eebc18..5b6a9270a 100644 --- a/crates/floresta-watch-only/src/lib.rs +++ b/crates/floresta-watch-only/src/lib.rs @@ -16,8 +16,8 @@ use bitcoin::ScriptBuf; use floresta_chain::BlockConsumer; use floresta_chain::UtxoData; use floresta_common::get_spk_hash; -use floresta_common::parse_descriptors; +pub mod descriptor; pub mod kv_database; #[cfg(any(test, feature = "memory-database"))] pub mod memory_database; @@ -40,6 +40,8 @@ use serde::Serialize; use sync::RwLock; use tracing::error; +use crate::descriptor::parse_descriptors; + #[derive(Debug)] pub enum WatchOnlyError { WalletNotInitialized, From 7f9b79b2ab27d155c6f88372ee0f546b420ccf65 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:52:48 -0300 Subject: [PATCH 9/9] chore(wallet): Centralize address derivation in `floresta-watch-only` and refactor wallet handling - Centralized address derivation in `floresta-watch-only`: - Address derivation is now automatically performed when pushing a descriptor. - Added `push_xpub` method to convert `XPUBs` into `descriptors`, push them, and derive addresses for the wallet. - Removed `walletInput` file from floresta-node: - This file was no longer necessary as its functionality (`XPUB`, `descriptor`, and address derivation) is now handled automatically by `floresta-watch-only`. --- crates/floresta-node/src/florestad.rs | 67 ++- crates/floresta-node/src/json_rpc/res.rs | 5 +- crates/floresta-node/src/json_rpc/server.rs | 21 +- crates/floresta-node/src/lib.rs | 1 - crates/floresta-node/src/wallet_input.rs | 148 ----- crates/floresta-watch-only/Cargo.toml | 2 +- .../floresta-watch-only/src/descriptor/mod.rs | 505 +++++++++++++----- .../src/descriptor/slip132.rs | 26 +- crates/floresta-watch-only/src/lib.rs | 59 +- 9 files changed, 495 insertions(+), 339 deletions(-) delete mode 100644 crates/floresta-node/src/wallet_input.rs diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index 545ae43c8..dc5412e17 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -4,12 +4,15 @@ use std::net::Ipv4Addr; use std::net::SocketAddr; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Arc; use std::sync::Mutex; #[cfg(feature = "json-rpc")] use std::sync::OnceLock; +use bitcoin::Address; pub use bitcoin::Network; +use bitcoin::ScriptBuf; #[cfg(feature = "zmq-server")] use floresta_chain::pruned_utreexo::BlockchainInterface; pub use floresta_chain::AssumeUtreexoValue; @@ -58,7 +61,6 @@ use crate::error::FlorestadError; use crate::florestad::fs::OpenOptions; #[cfg(feature = "json-rpc")] use crate::json_rpc; -use crate::wallet_input::InitialWalletSetup; #[cfg(feature = "zmq-server")] use crate::zmq::ZMQServer; @@ -742,11 +744,10 @@ impl Florestad { Self::get_config_file(&default_path) } }; - let setup = self.prepare_wallet_setup(config_file)?; // Add the configured descriptors and addresses to the wallet - for descriptor in setup.descriptors { - if let Err(e) = wallet.push_descriptor(&descriptor.to_string()) { + for descriptor in self.get_descriptors(&config_file) { + if let Err(e) = wallet.push_descriptor(&descriptor) { if let WatchOnlyError::DescriptorDuplicate = e { warn!("Descriptor already exists in wallet, skipping: {descriptor}"); } else { @@ -754,34 +755,60 @@ impl Florestad { } } } - for addresses in setup.addresses { - wallet.cache_address(addresses.script_pubkey()); + + for xpub in self.get_xpubs(&config_file) { + if let Err(e) = wallet.push_xpub(&xpub, self.config.network) { + if let WatchOnlyError::DescriptorDuplicate = e { + warn!("Descriptor for the provided XPUB already exists in the wallet. Skipping: {xpub}"); + } else { + return Err(FlorestadError::from(e)); + } + } + } + + for address in self.get_addresses(&config_file)? { + wallet.cache_address(address); } info!("Wallet setup completed!"); Ok(()) } - /// Parses the configured list of xpubs, output descriptors and addresses to watch for, and - /// returns the constructed `InitialWalletSetup`. - fn prepare_wallet_setup( - &self, - config_file: ConfigFile, - ) -> Result { - let config = &self.config; + /// Get the wallet descriptors from the config file and the environment. + fn get_descriptors(&self, config_file: &ConfigFile) -> Vec { + let mut descriptors = Vec::new(); + descriptors.extend(self.config.wallet_descriptor.clone().unwrap_or_default()); + descriptors.extend(config_file.wallet.descriptors.clone().unwrap_or_default()); + + descriptors + } + /// Get the wallet xpubs from the config file and the environment + fn get_xpubs(&self, config_file: &ConfigFile) -> Vec { let mut xpubs = Vec::new(); - xpubs.extend(config.wallet_xpub.clone().unwrap_or_default()); - xpubs.extend(config_file.wallet.xpubs.unwrap_or_default()); + xpubs.extend(self.config.wallet_xpub.clone().unwrap_or_default()); + xpubs.extend(config_file.wallet.xpubs.clone().unwrap_or_default()); xpubs.extend(Self::get_key_from_env()); - let mut descriptors = Vec::new(); - descriptors.extend(config.wallet_descriptor.clone().unwrap_or_default()); - descriptors.extend(config_file.wallet.descriptors.unwrap_or_default()); + xpubs + } - let addresses = config_file.wallet.addresses.unwrap_or_default(); + /// Get the wallet addresses from the config file + fn get_addresses(&self, config_file: &ConfigFile) -> Result, FlorestadError> { + let addresses_string = config_file.wallet.addresses.clone().unwrap_or_default(); + + let addresses = addresses_string + .iter() + .map(|address| match Address::from_str(address) { + Ok(address) => Ok(address.assume_checked().script_pubkey()), + Err(e) => { + error!("Invalid address provided: {address} \nReason: {e:?}"); + Err(e) + } + }) + .collect::, _>>()?; - InitialWalletSetup::build(&xpubs, &descriptors, &addresses, config.network, 100) + Ok(addresses) } /// Get the default Electrum port for the Network and TLS combination. diff --git a/crates/floresta-node/src/json_rpc/res.rs b/crates/floresta-node/src/json_rpc/res.rs index bb475e86e..6773ddf01 100644 --- a/crates/floresta-node/src/json_rpc/res.rs +++ b/crates/floresta-node/src/json_rpc/res.rs @@ -5,6 +5,7 @@ use corepc_types::v30::GetBlockVerboseOne; use floresta_chain::extensions::HeaderExtError; use floresta_common::impl_error_from; use floresta_mempool::mempool::AcceptToMempoolError; +use floresta_watch_only::descriptor::DescriptorError; use serde::Deserialize; use serde::Serialize; @@ -163,7 +164,7 @@ pub enum JsonRpcError { InvalidScript, /// The provided descriptor is invalid, e.g., if it does not match the expected format - InvalidDescriptor(miniscript::Error), + InvalidDescriptor(DescriptorError), /// The requested block is not found in the blockchain BlockNotFound, @@ -283,7 +284,7 @@ impl From for JsonRpcError { } } -impl_error_from!(JsonRpcError, miniscript::Error, InvalidDescriptor); +impl_error_from!(JsonRpcError, DescriptorError, InvalidDescriptor); impl From> for JsonRpcError { fn from(e: floresta_watch_only::WatchOnlyError) -> Self { diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 5308823ae..a02ebfa34 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::net::SocketAddr; -use std::slice; use std::sync::Arc; use std::time::Instant; @@ -25,7 +24,6 @@ use bitcoin::Txid; use floresta_chain::ThreadSafeChain; use floresta_compact_filters::flat_filters_store::FlatFiltersStore; use floresta_compact_filters::network_filters::NetworkFilters; -use floresta_watch_only::descriptor::parse_descriptors; use floresta_watch_only::kv_database::KvDatabase; use floresta_watch_only::AddressCache; use floresta_watch_only::CachedTransaction; @@ -99,25 +97,8 @@ impl RpcImpl { } fn load_descriptor(&self, descriptor: String) -> Result { - let desc = slice::from_ref(&descriptor); - let mut parsed = parse_descriptors(desc)?; - - self.wallet.push_descriptor(&descriptor)?; + let addresses = self.wallet.push_descriptor(&descriptor)?; debug!("Descriptor pushed: {descriptor}"); - - // It's ok to unwrap because we know there is at least one element in the vector - let addresses = parsed.pop().unwrap(); - let addresses = (0..100) - .map(|index| { - let address = addresses - .at_derivation_index(index) - .unwrap() - .script_pubkey(); - self.wallet.cache_address(address.clone()); - address - }) - .collect::>(); - debug!("Rescanning with block filters for addresses: {addresses:?}"); let addresses = self.wallet.get_cached_addresses(); diff --git a/crates/floresta-node/src/lib.rs b/crates/floresta-node/src/lib.rs index 83365d6ea..89f6094b6 100644 --- a/crates/floresta-node/src/lib.rs +++ b/crates/floresta-node/src/lib.rs @@ -12,7 +12,6 @@ mod error; mod florestad; #[cfg(feature = "json-rpc")] mod json_rpc; -mod wallet_input; #[cfg(feature = "zmq-server")] mod zmq; diff --git a/crates/floresta-node/src/wallet_input.rs b/crates/floresta-node/src/wallet_input.rs deleted file mode 100644 index 990ba52c0..000000000 --- a/crates/floresta-node/src/wallet_input.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Handles different inputs, try to make sense out of it and store a sane descriptor at the end - -use std::str::FromStr; - -use bitcoin::Address; -use bitcoin::Network; -use floresta_watch_only::descriptor::parse_descriptors; -use floresta_watch_only::descriptor::parse_xpubs; -use miniscript::Descriptor; -use miniscript::DescriptorPublicKey; -use tracing::error; - -use crate::error::FlorestadError; - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct InitialWalletSetup { - pub(crate) descriptors: Vec>, - pub(crate) addresses: Vec
, -} - -impl InitialWalletSetup { - pub(crate) fn build( - xpubs: &[String], - initial_descriptors: &[String], - addresses: &[String], - network: Network, - addresses_per_descriptor: u32, - ) -> Result { - let mut descriptors = parse_xpubs(xpubs, network)?; - - descriptors.extend(parse_descriptors(initial_descriptors)?); - - descriptors.sort(); - descriptors.dedup(); - - let mut addresses = addresses - .iter() - .flat_map(|address| match Address::from_str(address) { - Ok(address) => Ok(address.require_network(network)), - Err(e) => { - error!("Invalid address provided: {address} \nReason: {e:?}"); - Err(e) - } - }) - .collect::, _>>()?; - - addresses.extend(descriptors.iter().flat_map(|descriptor| { - (0..addresses_per_descriptor).map(|index| { - descriptor - .at_derivation_index(index) - .expect("Error while deriving address") - .address(network) - .expect("Error while deriving address. Is this an active descriptor?") - }) - })); - - addresses.sort(); - addresses.dedup(); - - Ok(Self { - descriptors, - addresses, - }) - } -} - -#[cfg(test)] -pub mod test { - use super::*; - - #[test] - fn test_initial_wallet_build() { - use pretty_assertions::assert_eq; - let addresses_per_descriptor = 1; - let network = Network::Bitcoin; - // Build wallet from xpub (in this case a zpub from slip132 standard) - let w1_xpub = InitialWalletSetup::build(&[ - "zpub6qvVf5mN7DH14wr7oZS7xL6cpcFPDmxFEHBk18YpUF1qnroE1yGfW83eafbbi23dzRk7jrVXeJFMyCo3urmQpwkXtVnRmGmaJ3qVvdwx4mB".to_owned() - ], &[], &[], network, addresses_per_descriptor).unwrap(); - // Build same wallet from output descriptor - let w1_descriptor = InitialWalletSetup::build(&[], &[ - "wpkh(xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/<0;1>/*)".to_owned() - ], &[], network, addresses_per_descriptor).unwrap(); - // Using both methods the result should be the same - assert_eq!(w1_xpub, w1_descriptor); - // Both normal receiving descriptor and change descriptor should be present - assert_eq!( - w1_descriptor.descriptors - .iter() - .map(ToString::to_string) - .collect::>(), - vec![ - "wpkh(xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/0/*)#qua4l7ct", - "wpkh(xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/1/*)#3gc5ztgn" - ] - ); - // Receiving and change addresses - let addresses = vec![ - "bc1q88guum89mxwszau37m3y4p24renwlwgtkscl6x".to_owned(), - "bc1q24629yendf7q0dxnw362dqccn52vuz9s0z59hr".to_owned(), - ]; - assert_eq!( - w1_descriptor - .addresses - .iter() - .map(ToString::to_string) - .collect::>(), - addresses - ); - // We can build from these addresses - let w1_addresses = - InitialWalletSetup::build(&[], &[], &addresses, network, addresses_per_descriptor) - .unwrap(); - // And the result will be the same as from xpub/descriptor - assert_eq!(w1_descriptor.addresses, w1_addresses.addresses); - // We can also build from xpub, descriptor and addresses, at same time - let w1_all = - InitialWalletSetup::build(&[ - "zpub6qvVf5mN7DH14wr7oZS7xL6cpcFPDmxFEHBk18YpUF1qnroE1yGfW83eafbbi23dzRk7jrVXeJFMyCo3urmQpwkXtVnRmGmaJ3qVvdwx4mB".to_owned() - ], &[ - "wpkh(xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/<0;1>/*)".to_owned() - ], &addresses, network, addresses_per_descriptor).unwrap(); - // And the result should be the same, no duplication will happen - assert_eq!(w1_descriptor, w1_all); - } - - #[test] - fn test_initial_wallet_build_multisig_testnet() { - use pretty_assertions::assert_eq; - let addresses_per_descriptor = 1; - let network = Network::Testnet; - let w1_descriptor = InitialWalletSetup::build(&[], &[ - "wsh(sortedmulti(1,[54ff5a12/48h/1h/0h/2h]tpubDDw6pwZA3hYxcSN32q7a5ynsKmWr4BbkBNHydHPKkM4BZwUfiK7tQ26h7USm8kA1E2FvCy7f7Er7QXKF8RNptATywydARtzgrxuPDwyYv4x/<0;1>/*,[bcf969c0/48h/1h/0h/2h]tpubDEFdgZdCPgQBTNtGj4h6AehK79Jm4LH54JrYBJjAtHMLEAth7LuY87awx9ZMiCURFzFWhxToRJK6xp39aqeJWrG5nuW3eBnXeMJcvDeDxfp/<0;1>/*))#fuw35j0q".to_owned() - ], &[], network, addresses_per_descriptor).unwrap(); - let addresses = vec![ - "tb1q2eeqw57e7pmrh5w3wkrshctx2qk80vf4mu7l7ek3ne4hg3lmcrnqcwejgj".to_owned(), - "tb1q6dpyc3jyqelgfwksedef0k2244rcg4gf6wvqm463lk907es2m08qnrfky7".to_owned(), - ]; - assert_eq!( - w1_descriptor - .addresses - .iter() - .map(ToString::to_string) - .collect::>(), - addresses - ); - } -} diff --git a/crates/floresta-watch-only/Cargo.toml b/crates/floresta-watch-only/Cargo.toml index ed70177b1..028965d53 100644 --- a/crates/floresta-watch-only/Cargo.toml +++ b/crates/floresta-watch-only/Cargo.toml @@ -16,7 +16,7 @@ kv = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } tracing = { workspace = true } -miniscript = { workspace = true, features = ["compiler"] } +miniscript = { workspace = true, features = ["compiler", "no-std"] } # Local dependencies floresta-chain = { workspace = true } diff --git a/crates/floresta-watch-only/src/descriptor/mod.rs b/crates/floresta-watch-only/src/descriptor/mod.rs index 89c8840f1..7d254d9f3 100644 --- a/crates/floresta-watch-only/src/descriptor/mod.rs +++ b/crates/floresta-watch-only/src/descriptor/mod.rs @@ -1,7 +1,10 @@ -use std::str::FromStr; +use core::fmt; +use core::str::FromStr; use bitcoin::Network; +use bitcoin::ScriptBuf; use floresta_common::impl_error_from; +use miniscript::descriptor::ConversionError; use miniscript::Descriptor; use miniscript::DescriptorPublicKey; use miniscript::Error as MiniscriptError; @@ -22,46 +25,58 @@ pub enum DescriptorError { /// Error in miniscript MiniscriptError(MiniscriptError), + + DeriveDescriptorError(ConversionError), } impl_error_from!(DescriptorError, Slip132Error, XpubParseError); impl_error_from!(DescriptorError, MiniscriptError, MiniscriptError); +impl_error_from!(DescriptorError, ConversionError, DeriveDescriptorError); -pub fn parse_xpubs( - xpubs: &[String], - network: Network, -) -> Result>, DescriptorError> { - let mut descriptors = Vec::new(); - for key in xpubs { - // Check if the xpub network matches the expected network - let is_mainnet = is_xpub_mainnet(key.as_str())?; - if (is_mainnet && network != Network::Bitcoin) - || (!is_mainnet && network == Network::Bitcoin) - { - return Err(DescriptorError::XpubNetworkMismatch(key.clone())); +impl fmt::Display for DescriptorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DescriptorError::XpubParseError(err) => write!(f, "Xpub parse error: {}", err), + DescriptorError::XpubNetworkMismatch(key) => { + write!( + f, + "The inserted Xpub does not operate in this network: {}", + key + ) + } + DescriptorError::MiniscriptError(err) => write!(f, "Miniscript error: {}", err), + DescriptorError::DeriveDescriptorError(err) => { + write!(f, "Derive descriptor error: {}", err) + } } + } +} - // Parses the descriptor and get an external and change descriptors - let main_desc = generate_descriptor_from_xpub(key.as_str(), false)?; - let change_desc = generate_descriptor_from_xpub(key.as_str(), true)?; - descriptors.push(Descriptor::::from_str(&main_desc)?); - descriptors.push(Descriptor::::from_str(&change_desc)?); +pub fn parse_xpub(xpub: &str, network: Network) -> Result, DescriptorError> { + // Check if the xpub network matches the expected network + let is_mainnet = is_xpub_mainnet(xpub)?; + if (is_mainnet && network != Network::Bitcoin) || (!is_mainnet && network == Network::Bitcoin) { + return Err(DescriptorError::XpubNetworkMismatch(xpub.to_string())); } - Ok(descriptors) + + // Parses the descriptor and get an external and change descriptors + let main_desc = generate_descriptor_from_xpub(xpub, false)?; + let change_desc = generate_descriptor_from_xpub(xpub, true)?; + + Ok(vec![ + Descriptor::::from_str(&main_desc)?.to_string(), + Descriptor::::from_str(&change_desc)?.to_string(), + ]) } /// Takes an array of descriptors as `String`, performs sanity checks on each one /// and returns list of parsed descriptors. pub fn parse_descriptors( descriptors: &[String], -) -> Result>, MiniscriptError> { +) -> Result>, DescriptorError> { let descriptors = descriptors .iter() - .map(|descriptor| { - let descriptor = Descriptor::::from_str(descriptor.as_str())?; - descriptor.sanity_check()?; - descriptor.into_single_descriptors() - }) + .map(|descriptor| parse_and_split_descriptor(descriptor)) .collect::>, _>>()? .into_iter() .flatten() @@ -69,129 +84,241 @@ pub fn parse_descriptors( Ok(descriptors) } +/// Parses a descriptor string, validates it, and splits it into single descriptors. +pub fn parse_and_split_descriptor( + descriptor: &str, +) -> Result>, DescriptorError> { + let descriptor = Descriptor::::from_str(descriptor)?; + descriptor.sanity_check()?; + + let descriptors = descriptor.into_single_descriptors()?; + + Ok(descriptors) +} + +/// Derives addresses from a list of descriptors. +/// Parses each descriptor, validates it, and derives the specified number of addresses +/// starting from the given index. +pub fn derive_addresses_from_list_descriptors( + descriptors: &[String], + index: u32, + quantity: u32, +) -> Result, DescriptorError> { + let mut addresses = Vec::new(); + for desc in descriptors { + addresses.extend_from_slice(&derive_addresses_from_descriptor(desc, index, quantity)?); + } + + Ok(addresses) +} + +/// Derives addresses from a single descriptor string. +/// Splits the descriptor into single descriptors and derives addresses for each one. +pub fn derive_addresses_from_descriptor( + descriptor: &str, + index: u32, + quantity: u32, +) -> Result, DescriptorError> { + let descriptors = parse_and_split_descriptor(descriptor)?; + + let mut addresses = Vec::with_capacity(descriptors.len() * quantity as usize); + for desc in descriptors { + addresses.extend_from_slice(&derive_addresses_from_parsed_descriptor( + desc, index, quantity, + )?); + } + + Ok(addresses) +} + +/// Derives addresses from a parsed descriptor. +/// Generates the specified number of addresses starting from the given index. +pub fn derive_addresses_from_parsed_descriptor( + descriptor: Descriptor, + index: u32, + quantity: u32, +) -> Result, DescriptorError> { + let mut addresses = Vec::with_capacity(quantity as usize); + for i in index..index + quantity { + let address = descriptor.at_derivation_index(i)?.script_pubkey(); + addresses.push(address); + } + + Ok(addresses) +} + #[cfg(test)] mod test { + use std::vec; + use bitcoin::Network; use super::*; - const XPUB: &str = "xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs"; - const YPUB: &str = "ypub6XmBfjfmuYD1bjv5RCEHU8jD1NPGZh6NRTGDB8ndQsd7MPnzhDhAsdrF9sK8Z4G9FvcFBHoGsZqhsDHtenca3K5QigYWVKXvkAx6HBxVGYM"; - const ZPUB: &str = "zpub6rFvSvP5VbpXwej2L5WseLfxfdUzSczs9DK9v9mpXgXNqjFhtfUTRGkQKr7sXKNyrrzhd2LCysGqts1oT3b1PJji16xWzcmNMfhmZ8kkLZ1"; - const XPUB_MAINNET: [&str; 3] = [XPUB, YPUB, ZPUB]; + struct TestCase { + xpub: &'static str, + default_descriptor: &'static str, + main_descriptor: &'static str, + change_descriptor: &'static str, + main_address: &'static str, + change_address: &'static str, + main_script: &'static str, + change_script: &'static str, + network: Network, + } + + const TEST_CASE_XPUB: TestCase = TestCase { + xpub: "xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs", + default_descriptor: "pkh(xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs/<0;1>/*)", + main_descriptor: "pkh(xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs/0/*)#32jmvyn7", + change_descriptor: "pkh(xpub6CPimhNogJosVzpueNmrWEfSHc2YTXG1ZyE6TBV4Nx6UxZ7zKSGYv9hKxNjiFY5o1vz7QeZa2m6vQmyndDrkECk8cShWYWxe1gqa1xJEkgs/1/*)#q7h633rx", + main_address: "1JHazecJrjbxBMQgRcyV3JCQJwVbHBjH5t", + change_address: "1JbCXSeZHizJDQANsgtLBjo5y24JNMyGTB", + main_script: "OP_DUP OP_HASH160 OP_PUSHBYTES_20 bd9d2ba0e12d433a4b3c81fbf6457f41a4b37ffe OP_EQUALVERIFY OP_CHECKSIG", + change_script: "OP_DUP OP_HASH160 OP_PUSHBYTES_20 c0f1e6c8977d40f9a8ffe9b06120ae4c2833e9ef OP_EQUALVERIFY OP_CHECKSIG", + network: Network::Bitcoin, + }; + + const TEST_CASE_YPUB: TestCase = TestCase { + xpub: "ypub6XmBfjfmuYD1bjv5RCEHU8jD1NPGZh6NRTGDB8ndQsd7MPnzhDhAsdrF9sK8Z4G9FvcFBHoGsZqhsDHtenca3K5QigYWVKXvkAx6HBxVGYM", + default_descriptor: "sh(wpkh(xpub6CvvN4zrkrfXkSixaqSfG3dhqQEpd56sWLjzPjtk2sFEJHymSZXcFaC78fMYZ9cDrHVSRpCiQuV9yvgKw6CZF5PorLr5uQiSUStStZjpSSV/<0;1>/*))", + main_descriptor: "sh(wpkh(xpub6CvvN4zrkrfXkSixaqSfG3dhqQEpd56sWLjzPjtk2sFEJHymSZXcFaC78fMYZ9cDrHVSRpCiQuV9yvgKw6CZF5PorLr5uQiSUStStZjpSSV/0/*))#657qlqhe", + change_descriptor: "sh(wpkh(xpub6CvvN4zrkrfXkSixaqSfG3dhqQEpd56sWLjzPjtk2sFEJHymSZXcFaC78fMYZ9cDrHVSRpCiQuV9yvgKw6CZF5PorLr5uQiSUStStZjpSSV/1/*))#uhk9ydud", + main_address: "31sQy1RG4Y6sCtCpmXrtiJooqzBozRUTU6", + change_address: "33kzJbaR4EDzEoigsKuLata1svSqNGsdSo", + main_script: "OP_HASH160 OP_PUSHBYTES_20 01f764ff1e1f27740b0b638b0251bec1bece0964 OP_EQUAL", + change_script: "OP_HASH160 OP_PUSHBYTES_20 16b0903438a739fc09bb7e894895df291bb8ee19 OP_EQUAL", + network: Network::Bitcoin, + }; + + const TEST_CASE_ZPUB: TestCase = TestCase { + xpub: "zpub6rFvSvP5VbpXwej2L5WseLfxfdUzSczs9DK9v9mpXgXNqjFhtfUTRGkQKr7sXKNyrrzhd2LCysGqts1oT3b1PJji16xWzcmNMfhmZ8kkLZ1", + default_descriptor: "wpkh(xpub6CbPqb3FCEjaF4LnfMwdEAUxKhC6ZP1sJzGiMMz3mfmcjXdFPM9LB9S8HSChXW593am685964YZk8Hng1ekynqNWGRZfpo8PpDaUmyvQqvY/<0;1>/*)", + main_descriptor: "wpkh(xpub6CbPqb3FCEjaF4LnfMwdEAUxKhC6ZP1sJzGiMMz3mfmcjXdFPM9LB9S8HSChXW593am685964YZk8Hng1ekynqNWGRZfpo8PpDaUmyvQqvY/0/*)#z2djk607", + change_descriptor: "wpkh(xpub6CbPqb3FCEjaF4LnfMwdEAUxKhC6ZP1sJzGiMMz3mfmcjXdFPM9LB9S8HSChXW593am685964YZk8Hng1ekynqNWGRZfpo8PpDaUmyvQqvY/1/*)#n7gnt0lx", + main_address: "bc1qz4ta3h4ga6hdqa090wfpr83asyz5z40t272wez", + change_address: "bc1qjeq39p3mpvmwqwkpaqe9hdjgfhfa8w5z87tnp4", + main_script: "OP_0 OP_PUSHBYTES_20 1557d8dea8eeaed075e57b92119e3d81054155eb", + change_script: "OP_0 OP_PUSHBYTES_20 964112863b0b36e03ac1e8325bb6484dd3d3ba82", + network: Network::Bitcoin, + }; + + const TEST_CASE_TPUB: TestCase = TestCase { + xpub: "tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5", + default_descriptor: "pkh(tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5/<0;1>/*)", + main_descriptor: "pkh(tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5/0/*)#8zp7ryrl", + change_descriptor: "pkh(tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5/1/*)#kkyl73n8", + main_address: "mhk8YjtyHigqGMiEGaf8cnNW9Game9exC6", + change_address: "mmuYagUFFQtAzw8Ts7afED6HFboCy4e8WR", + main_script: "OP_DUP OP_HASH160 OP_PUSHBYTES_20 186e37d051208d814da8988b596e515ac79c0336 OP_EQUALVERIFY OP_CHECKSIG", + change_script: "OP_DUP OP_HASH160 OP_PUSHBYTES_20 461686e57db4157808a9d4e935ae35d60fae0676 OP_EQUALVERIFY OP_CHECKSIG", + network: Network::Testnet, + }; - const TPUB: &str = "tpubDC73PMTHeKDXnFwNFz8CLBy2VVx4D85WW2vbzwVLwCD9zkQ6Vj97muhLRTbKvmue1PyVQLwizvBW6v2SD1LnzbeuHnRsDYQZGE8urTZHMn5"; - const UPUB: &str = "upub5E3Vhaq9uVmz426B5FME1csAY8tvQ8vRqt7WnGyiJ4CoknpyM2WJk4B6uSh2kud3r8RJHTzS5jLFnWNRThKZyew6tDX2eXGMyTvfa8AVwyK"; - const VPUB: &str = "vpub5Zrsj9pYeJLwTfggbSQYZDdpEpZ4M1qB1EUKfXB9bjsookSNjM6c6eFTYfjb8KcGJV4ZqAYScBvC7hyDbbWKCHVcC6RETNJUfwUFvnHJM8Y"; - const XPUB_TESTNET: [&str; 3] = [TPUB, UPUB, VPUB]; + const TEST_CASE_UPUB: TestCase = TestCase { + xpub: "upub5E3Vhaq9uVmz426B5FME1csAY8tvQ8vRqt7WnGyiJ4CoknpyM2WJk4B6uSh2kud3r8RJHTzS5jLFnWNRThKZyew6tDX2eXGMyTvfa8AVwyK", + default_descriptor: "sh(wpkh(tpubDCuv8pfb4pMsshrP2WhBqoV3PARvDPPz8rGUV1iWmz6LfNwNBDr5kgpMD6eaH8Y3rxJd9UHyzpDx8Yhj1eQrFoSCYqMc5nP4Nbi1VvJmNco/<0;1>/*))", + main_descriptor: "sh(wpkh(tpubDCuv8pfb4pMsshrP2WhBqoV3PARvDPPz8rGUV1iWmz6LfNwNBDr5kgpMD6eaH8Y3rxJd9UHyzpDx8Yhj1eQrFoSCYqMc5nP4Nbi1VvJmNco/0/*))#sh4fvsj4", + change_descriptor: "sh(wpkh(tpubDCuv8pfb4pMsshrP2WhBqoV3PARvDPPz8rGUV1iWmz6LfNwNBDr5kgpMD6eaH8Y3rxJd9UHyzpDx8Yhj1eQrFoSCYqMc5nP4Nbi1VvJmNco/1/*))#k5avhaep", + main_address: "2NBfJvMZadWb8mwtV3F4FXTqAJs3pkYNdn8", + change_address: "2MznomgtTHMBvsMqPwwE3sSLzj6F8w3Mnyi", + main_script: "OP_HASH160 OP_PUSHBYTES_20 ca005d4bd7b470a9e12710789cac3812c16146a4 OP_EQUAL", + change_script: "OP_HASH160 OP_PUSHBYTES_20 52c1f9cdaa84c6552678051e6322a8f5ff6687ae OP_EQUAL", + network: Network::Testnet, + }; + + const TEST_CASE_VPUB: TestCase = TestCase { + xpub: "vpub5Zrsj9pYeJLwTfggbSQYZDdpEpZ4M1qB1EUKfXB9bjsookSNjM6c6eFTYfjb8KcGJV4ZqAYScBvC7hyDbbWKCHVcC6RETNJUfwUFvnHJM8Y", + default_descriptor: "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/<0;1>/*)", + main_descriptor: "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/0/*)#f8w55tty", + change_descriptor: "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/1/*)#cnt4f7mu", + main_address: "tb1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfd6zxzqa", + change_address: "tb1qzplphjt68gs0lwvxrq70t9j9cva8ky7r7ucz2g", + main_script: "OP_0 OP_PUSHBYTES_20 f6680511fb0b33082e3235f35a01f9500a86d92d", + change_script: "OP_0 OP_PUSHBYTES_20 107e1bc97a3a20ffb986183cf59645c33a7b13c3", + network: Network::Testnet, + }; + + const TEST_CASE_VPUB_REGTEST: TestCase = TestCase { + xpub: "vpub5Zrsj9pYeJLwTfggbSQYZDdpEpZ4M1qB1EUKfXB9bjsookSNjM6c6eFTYfjb8KcGJV4ZqAYScBvC7hyDbbWKCHVcC6RETNJUfwUFvnHJM8Y", + default_descriptor: "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/<0;1>/*)", + main_descriptor: "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/0/*)#f8w55tty", + change_descriptor: "wpkh(tpubDDu2riz4ewPMS4FmiLxtBKABuswcDeKEP674as24hfPTfEjYJtGpVDEZq7jYedsLufq5whFS4cTLaTgxRrBagCK6zNZPJibgoMBxTvUcVFf/1/*)#cnt4f7mu", + main_address: "bcrt1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfdctl0h5", + change_address: "bcrt1qzplphjt68gs0lwvxrq70t9j9cva8ky7ru4p0ap", + main_script: "OP_0 OP_PUSHBYTES_20 f6680511fb0b33082e3235f35a01f9500a86d92d", + change_script: "OP_0 OP_PUSHBYTES_20 107e1bc97a3a20ffb986183cf59645c33a7b13c3", + network: Network::Regtest, + }; + + const TEST_CASES: [&TestCase; 7] = [ + &TEST_CASE_XPUB, + &TEST_CASE_YPUB, + &TEST_CASE_ZPUB, + &TEST_CASE_TPUB, + &TEST_CASE_UPUB, + &TEST_CASE_VPUB, + &TEST_CASE_VPUB_REGTEST, + ]; #[test] - fn test_xpub_parsing() { - // xpub | network | (main address, change address) - let cases = &[ - ( - XPUB, - Network::Bitcoin, - [ - "1JHazecJrjbxBMQgRcyV3JCQJwVbHBjH5t", - "1JbCXSeZHizJDQANsgtLBjo5y24JNMyGTB", - ], - ), - ( - YPUB, - Network::Bitcoin, - [ - "31sQy1RG4Y6sCtCpmXrtiJooqzBozRUTU6", - "33kzJbaR4EDzEoigsKuLata1svSqNGsdSo", - ], - ), - ( - ZPUB, - Network::Bitcoin, - [ - "bc1qz4ta3h4ga6hdqa090wfpr83asyz5z40t272wez", - "bc1qjeq39p3mpvmwqwkpaqe9hdjgfhfa8w5z87tnp4", - ], - ), - ( - TPUB, - Network::Testnet, - [ - "mhk8YjtyHigqGMiEGaf8cnNW9Game9exC6", - "mmuYagUFFQtAzw8Ts7afED6HFboCy4e8WR", - ], - ), - ( - UPUB, - Network::Testnet, - [ - "2NBfJvMZadWb8mwtV3F4FXTqAJs3pkYNdn8", - "2MznomgtTHMBvsMqPwwE3sSLzj6F8w3Mnyi", - ], - ), - ( - VPUB, - Network::Testnet, - [ - "tb1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfd6zxzqa", - "tb1qzplphjt68gs0lwvxrq70t9j9cva8ky7r7ucz2g", - ], - ), - ( - VPUB, - Network::Regtest, - [ - "bcrt1q7e5q2y0mpvesst3jxhe45q0e2q9gdkfdctl0h5", - "bcrt1qzplphjt68gs0lwvxrq70t9j9cva8ky7ru4p0ap", - ], - ), - ]; - - for (descriptor, network, addresses) in cases { - let parsed = parse_xpubs(&[descriptor.to_string()], *network).unwrap(); - assert_eq!(parsed.len(), 2); - - let main_desc = parsed[0].clone(); + fn test_parse_xpub_valid_cases() { + let cases = TEST_CASES; + + for &tc in &cases { + let descriptors_string = parse_xpub(tc.xpub, tc.network).unwrap(); + assert_eq!(descriptors_string.len(), 2); + assert_eq!(descriptors_string[0], tc.main_descriptor); + assert_eq!(descriptors_string[1], tc.change_descriptor); + + let descriptors = parse_descriptors(&descriptors_string).unwrap(); + assert_eq!(descriptors.len(), 2); + + let main_desc = descriptors[0].clone(); let main_address = main_desc .at_derivation_index(0) .unwrap() - .address(*network) + .address(tc.network) .unwrap(); - assert_eq!(main_address.to_string(), addresses[0]); + assert_eq!(main_address.to_string(), tc.main_address); - let change_desc = parsed[1].clone(); + let change_desc = descriptors[1].clone(); let change_address = change_desc .at_derivation_index(0) .unwrap() - .address(*network) + .address(tc.network) .unwrap(); - assert_eq!(change_address.to_string(), addresses[1]); + assert_eq!(change_address.to_string(), tc.change_address); } } #[test] fn test_parse_xpub_with_correct_network() { - fn check(xpubs: [&str; 3], network: Network) { - for xpub in xpubs { - let parsed = parse_xpubs(&[xpub.to_string()], network); - assert!(parsed.is_ok()); - } + fn check(xpub: &str, network: Network) { + let parsed = parse_xpub(xpub, network); + assert!(parsed.is_ok()); } - check(XPUB_MAINNET, Network::Bitcoin); + let cases = TEST_CASES; - check(XPUB_TESTNET, Network::Regtest); - check(XPUB_TESTNET, Network::Testnet); - check(XPUB_TESTNET, Network::Testnet4); - check(XPUB_TESTNET, Network::Signet); + for &tc in &cases { + check(tc.xpub, tc.network); + } } #[test] fn test_parse_xpub_with_wrong_network() { - fn check(xpubs: [&str; 3], network: Network) { - for xpub in xpubs { - let parsed = parse_xpubs(&[xpub.to_string()], network); + fn check(xpub: &str, network: Network) { + let wrong_network = if network == Network::Bitcoin { + vec![Network::Testnet, Network::Regtest, Network::Signet] + } else { + vec![Network::Bitcoin] + }; + + for net in wrong_network { + let parsed = parse_xpub(xpub, net); let err = parsed.err().unwrap(); + if let DescriptorError::XpubNetworkMismatch(actual) = err { assert_eq!(actual, xpub.to_string()); } else { @@ -200,26 +327,27 @@ mod test { } } - check(XPUB_MAINNET, Network::Regtest); - check(XPUB_MAINNET, Network::Testnet); - check(XPUB_MAINNET, Network::Testnet4); - check(XPUB_MAINNET, Network::Signet); + let cases = TEST_CASES; - check(XPUB_TESTNET, Network::Bitcoin); + for &tc in &cases { + check(tc.xpub, tc.network); + } } #[test] - fn test_descriptor_parsing() { + fn test_parse_descriptors_valid_cases() { // singlesig - assert_eq!( - parse_descriptors(&[ - "wpkh([a5b13c0e/84h/0h/0h]xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/<0;1>/*)#n8sgapuv".to_owned() - ]).unwrap(), - parse_descriptors(&[ - "wpkh([a5b13c0e/84'/0'/0']xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/0/*)#wg8dh3s7".to_owned(), - "wpkh([a5b13c0e/84'/0'/0']xpub6CFy3kRXorC3NMTt8qrsY9ucUfxVLXyFQ49JSLm3iEG5gfAmWewYFzjNYFgRiCjoB9WWEuJQiyYGCdZvUTwPEUPL9pPabT8bkbiD9Po47XG/1/*)#luzv2yqx".to_owned() - ]).unwrap() - ); + for cases in TEST_CASES { + assert_eq!( + parse_descriptors(&[cases.default_descriptor.to_owned()]).unwrap(), + parse_descriptors(&[ + cases.main_descriptor.to_owned(), + cases.change_descriptor.to_owned() + ]) + .unwrap() + ); + } + // multisig assert_eq!( parse_descriptors(&[ @@ -231,4 +359,117 @@ mod test { ]).unwrap() ); } + + #[test] + fn test_parse_and_split_descriptor_valid_cases() { + for cases in TEST_CASES { + let descriptors = parse_and_split_descriptor(cases.default_descriptor).unwrap(); + let expected_descriptor = [cases.main_descriptor, cases.change_descriptor] + .iter() + .map(|d| Descriptor::::from_str(d).unwrap()) + .collect::>(); + + assert_eq!(descriptors, expected_descriptor); + } + } + + #[test] + fn test_derive_addresses_from_list_descriptors_valid_cases() { + let cases = TEST_CASES; + let all_default_descriptors: Vec = cases + .iter() + .map(|tc| tc.default_descriptor.to_string()) + .collect(); + + let all_script_buff: Vec = cases + .iter() + .flat_map(|tc| vec![tc.main_script.to_string(), tc.change_script.to_string()]) + .collect(); + + let addresses_derived = + derive_addresses_from_list_descriptors(&all_default_descriptors, 0, 1).unwrap(); + + assert_eq!(addresses_derived.len(), all_script_buff.len()); + assert_eq!( + addresses_derived + .iter() + .map(|script| script.to_string()) + .collect::>(), + all_script_buff + ); + } + + #[test] + fn test_derive_addresses_from_descriptor_valid_cases() { + let cases = TEST_CASES; + for &tc in &cases { + let list_script_buff = + derive_addresses_from_descriptor(tc.default_descriptor, 0, 1).unwrap(); + assert_eq!(list_script_buff.len(), 2); + assert_eq!(list_script_buff[0].to_string(), tc.main_script); + assert_eq!(list_script_buff[1].to_string(), tc.change_script); + + let main_script_buff = + derive_addresses_from_descriptor(tc.main_descriptor, 0, 1).unwrap(); + assert_eq!(main_script_buff.len(), 1); + assert_eq!(main_script_buff[0].to_string(), tc.main_script); + + let change_script_buff = + derive_addresses_from_descriptor(tc.change_descriptor, 0, 1).unwrap(); + assert_eq!(change_script_buff.len(), 1); + assert_eq!(change_script_buff[0].to_string(), tc.change_script); + } + } + + #[test] + fn test_derive_addresses_from_parsed_descriptor_valid_cases() { + for &tc in &TEST_CASES { + let descriptor = parse_and_split_descriptor(tc.main_descriptor).unwrap()[0].clone(); + let derived_addresses = + derive_addresses_from_parsed_descriptor(descriptor, 0, 1).unwrap(); + + assert_eq!(derived_addresses.len(), 1); + assert_eq!(derived_addresses[0].to_string(), tc.main_script); + } + } + + #[test] + fn test_invalid_descriptor_parsing() { + fn check(result: Result>, DescriptorError>) { + assert!(result.is_err()); + if let Err(DescriptorError::MiniscriptError(_)) = result { + // Expected error + } else { + panic!("Expected MiniscriptError"); + } + } + + let invalid_descriptor = "invalid(descriptor)"; + + let result = parse_descriptors(&[invalid_descriptor.to_string()]); + check(result); + + let result = parse_and_split_descriptor(invalid_descriptor); + check(result); + } + + #[test] + fn test_derive_addresses_with_invalid_descriptor() { + fn check(result: Result, DescriptorError>) { + assert!(result.is_err()); + if let Err(DescriptorError::MiniscriptError(_)) = result { + // Expected error + } else { + panic!("Expected MiniscriptError"); + } + } + let invalid_descriptor = "invalid(descriptor)"; + + let result = derive_addresses_from_descriptor(invalid_descriptor, 0, 1); + check(result); + + let result = + derive_addresses_from_list_descriptors(&[invalid_descriptor.to_string()], 0, 1); + check(result); + } } diff --git a/crates/floresta-watch-only/src/descriptor/slip132.rs b/crates/floresta-watch-only/src/descriptor/slip132.rs index 1953ac7e2..6cabc6b68 100644 --- a/crates/floresta-watch-only/src/descriptor/slip132.rs +++ b/crates/floresta-watch-only/src/descriptor/slip132.rs @@ -7,7 +7,8 @@ //! Bitcoin SLIP-132 standard implementation for parsing custom xpub/xpriv key //! formats -use std::fmt::Debug; +use core::fmt; +use core::fmt::Debug; use bitcoin::base58; use bitcoin::bip32; @@ -113,6 +114,29 @@ pub enum Error { NoSupportXpubMultisig, } +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Base58(e) => write!(f, "Base58 error: {}", e), + Error::Hex(e) => write!(f, "Hex error: {}", e), + Error::CannotDeriveFromHardenedKey => { + write!(f, "Cannot derive from hardened key") + } + Error::InvalidChildNumber(no) => write!(f, "Invalid child number: {}", no), + Error::InvalidChildNumberFormat => write!(f, "Invalid child number format"), + Error::InvalidDerivationPathFormat => write!(f, "Invalid derivation path format"), + Error::UnknownVersion(ver) => write!(f, "Unknown version bytes: {:02X?}", ver), + Error::WrongExtendedKeyLength(len) => { + write!(f, "Wrong extended key length: {}", len) + } + Error::UnknownSlip32Prefix => write!(f, "Unknown SLIP-132 prefix"), + Error::InternalFailure => write!(f, "Internal failure"), + Error::NoSupportXpriv => write!(f, "No support for xpriv keys"), + Error::NoSupportXpubMultisig => write!(f, "No support for xpub multisig keys"), + } + } +} + fn extract_slip132_prefix(s: &str) -> Result<[u8; 4], Error> { let data = base58::decode_check(s)?; let mut prefix = [0u8; 4]; diff --git a/crates/floresta-watch-only/src/lib.rs b/crates/floresta-watch-only/src/lib.rs index 5b6a9270a..034e4693b 100644 --- a/crates/floresta-watch-only/src/lib.rs +++ b/crates/floresta-watch-only/src/lib.rs @@ -12,6 +12,7 @@ use core::cmp::Ordering; use core::fmt::Debug; use bitcoin::hashes::sha256; +use bitcoin::Network; use bitcoin::ScriptBuf; use floresta_chain::BlockConsumer; use floresta_chain::UtxoData; @@ -40,7 +41,10 @@ use serde::Serialize; use sync::RwLock; use tracing::error; -use crate::descriptor::parse_descriptors; +use crate::descriptor::derive_addresses_from_descriptor; +use crate::descriptor::derive_addresses_from_list_descriptors; +use crate::descriptor::parse_xpub; +use crate::descriptor::DescriptorError; #[derive(Debug)] pub enum WatchOnlyError { @@ -48,6 +52,7 @@ pub enum WatchOnlyError { TransactionNotFound, DatabaseError(DatabaseError), DescriptorDuplicate, + InvalidDescriptor(DescriptorError), } impl Display for WatchOnlyError { @@ -65,6 +70,9 @@ impl Display for WatchOnlyError { WatchOnlyError::DescriptorDuplicate => { write!(f, "Descriptor is already cached") } + WatchOnlyError::InvalidDescriptor(e) => { + write!(f, "Invalid descriptor: {e:?}") + } } } } @@ -352,17 +360,15 @@ impl AddressCacheInner { fn derive_addresses(&mut self) -> Result<(), WatchOnlyError> { let mut stats = self.database.get_stats()?; let descriptors = self.database.descs_get()?; - let descriptors = parse_descriptors(&descriptors).expect("We validate those descriptors"); - for desc in descriptors { - let index = stats.derivation_index; - for idx in index..(index + 100) { - let script = desc - .at_derivation_index(idx) - .expect("We validate those descriptors before saving") - .script_pubkey(); - self.cache_address(script); - } - } + + let addresses = + derive_addresses_from_list_descriptors(&descriptors, stats.derivation_index, 100) + .map_err(WatchOnlyError::InvalidDescriptor)?; + + addresses.iter().for_each(|address| { + self.cache_address(address.clone()); + }); + stats.derivation_index += 100; Ok(self.database.save_stats(&stats)?) } @@ -644,13 +650,38 @@ impl AddressCache { inner.address_map.contains_key(script_hash) } - pub fn push_descriptor(&self, descriptor: &str) -> Result<(), WatchOnlyError> { + /// Adds a descriptor to the wallet, derives addresses, and caches them. + pub fn push_descriptor( + &self, + descriptor: &str, + ) -> Result, WatchOnlyError> { if self.is_cached(&String::from(descriptor))? { return Err(WatchOnlyError::DescriptorDuplicate); } + let address_descriptors = derive_addresses_from_descriptor(descriptor, 0, 100) + .map_err(WatchOnlyError::InvalidDescriptor)?; + + for address in address_descriptors.clone() { + self.cache_address(address); + } + let inner = self.inner.write().expect("poisoned lock"); - Ok(inner.database.desc_save(descriptor)?) + inner.database.desc_save(descriptor)?; + + Ok(address_descriptors) + } + + /// Adds an XPUB to the wallet, derives descriptors from it, saves these descriptors persistently, + /// derives addresses, and caches them. + pub fn push_xpub(&self, xpub: &str, network: Network) -> Result<(), WatchOnlyError> { + let descriptors = parse_xpub(xpub, network).map_err(WatchOnlyError::InvalidDescriptor)?; + + for descriptor in descriptors { + self.push_descriptor(&descriptor)?; + } + + Ok(()) } pub fn get_position(&self, txid: &Txid) -> Option {