Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/cryptoadvance/specter/util/wallet_importer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import logging
import re

import requests
from embit.descriptor import Descriptor
Expand Down Expand Up @@ -517,6 +518,12 @@ def parse_wallet_data_import(cls, wallet_data):

wallet_name = wallet_data.get("label", "Imported Wallet")
recv_descriptor = wallet_data.get("descriptor", None)

# Handle combined descriptors with <0;1> syntax (BIP 389 multipath)
# Convert to receive-only descriptor for backward compatibility with import logic
# Only replace <0;1> in the final derivation path (before /*), not in key paths
if recv_descriptor and "<0;1>" in recv_descriptor:
recv_descriptor = re.sub(r'<0;1>(/\*)', r'0\1', recv_descriptor)

if wallet_name is None:
raise SpecterError(
Expand Down
2 changes: 1 addition & 1 deletion src/cryptoadvance/specter/wallet/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ def account_map(self):
account_map_dict = {
"label": self.name,
"blockheight": self.blockheight,
"descriptor": self.recv_descriptor,
"descriptor": add_checksum(self.descriptor.to_string()),
"devices": [{"type": d.device_type, "label": d.name} for d in self.devices],
}
return json.dumps(account_map_dict)
Expand Down
55 changes: 54 additions & 1 deletion tests/test_wallet.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from asyncio.streams import FlowControlMixin
import json
import random
import re
import time
from typing import List
import pytest, logging
from embit.descriptor import Descriptor as EmbitDescriptor
from cryptoadvance.specter.util.psbt import SpecterPSBT
from cryptoadvance.specter.commands.psbt_creator import PsbtCreator
from cryptoadvance.specter.wallet.txlist import WalletAwareTxItem
Expand Down Expand Up @@ -367,3 +369,54 @@
assert full_utxo[1]["amount"] == 2
assert full_utxo[2]["amount"] == 3
assert full_utxo[3]["amount"] == 20


@pytest.mark.slow
def test_account_map_combined_descriptor(funded_hot_wallet_1: Wallet):
"""
Test that account_map property returns a combined descriptor with <0;1> syntax
instead of just the receive descriptor with /0/*

When exporting wallets, only the receive descriptor /0/* was included,
but it should include both receive and change addresses using multipath descriptors.
"""
wallet = funded_hot_wallet_1

# Parse the account_map JSON
account_map_dict = json.loads(wallet.account_map)

# Check that descriptor key exists
assert "descriptor" in account_map_dict
descriptor_str = account_map_dict["descriptor"]

# The descriptor should use BIP 389 multipath syntax <0;1>
# not just the receive path /0/*
assert "<0;1>" in descriptor_str, (

Check failure on line 394 in tests/test_wallet.py

View check run for this annotation

Cirrus CI / test

tests/test_wallet.py#L394

tests.test_wallet.test_account_map_combined_descriptor
Raw output
funded_hot_wallet_1 = <Wallet name=a_hotwallet_948909 alias=a_hotwallet_948909>

    @pytest.mark.slow
    def test_account_map_combined_descriptor(funded_hot_wallet_1: Wallet):
        """
        Test that account_map property returns a combined descriptor with <0;1> syntax
        instead of just the receive descriptor with /0/*
    
        When exporting wallets, only the receive descriptor /0/* was included,
        but it should include both receive and change addresses using multipath descriptors.
        """
        wallet = funded_hot_wallet_1
    
        # Parse the account_map JSON
        account_map_dict = json.loads(wallet.account_map)
    
        # Check that descriptor key exists
        assert "descriptor" in account_map_dict
        descriptor_str = account_map_dict["descriptor"]
    
        # The descriptor should use BIP 389 multipath syntax <0;1>
        # not just the receive path /0/*
>       assert "<0;1>" in descriptor_str, (
            f"Expected descriptor with <0;1> multipath syntax, "
            f"but got: {descriptor_str}"
        )
E       AssertionError: Expected descriptor with <0;1> multipath syntax, but got: wpkh([915227ed/84h/1h/0h]tpubDCFZjEWPiCL57asbtmeSTMq2GW9GnBfK7f4MjKtVsRux3nC66uefXd3SPqaQYtRmPg6Aacqj2R2qQ1BwCtm6xmr9zqJSLnyhpZQgpfMCbgx/{0,1}/*)#yxak3f5f
E       assert '<0;1>' in 'wpkh([915227ed/84h/1h/0h]tpubDCFZjEWPiCL57asbtmeSTMq2GW9GnBfK7f4MjKtVsRux3nC66uefXd3SPqaQYtRmPg6Aacqj2R2qQ1BwCtm6xmr9zqJSLnyhpZQgpfMCbgx/{0,1}/*)#yxak3f5f'

tests/test_wallet.py:394: AssertionError
f"Expected descriptor with <0;1> multipath syntax, "
f"but got: {descriptor_str}"
)

# Should not have single-branch /0/* or /1/* in the descriptor
# Check that it doesn't end with /0/* followed by checksum or /1/* followed by checksum
# Note: Descriptor checksums use charset "qpzry9x8gf2tvdw0s3jn54khce6mua7l" (lowercase + digits only)
# but we include uppercase for future-proofing
assert not re.search(r'/0/\*#[a-zA-Z0-9]+$', descriptor_str), (
f"Descriptor should not end with /0/* (receive only), "
f"got: {descriptor_str}"
)
assert not re.search(r'/1/\*#[a-zA-Z0-9]+$', descriptor_str), (
f"Descriptor should not end with /1/* (change only), "
f"got: {descriptor_str}"
)

# Verify it has a checksum (format: descriptor#checksum)
assert "#" in descriptor_str, (
f"Descriptor should have a checksum, got: {descriptor_str}"
)

# Verify the descriptor can be parsed and has 2 branches
parsed_desc = EmbitDescriptor.from_string(descriptor_str)
assert parsed_desc.num_branches == 2, (
f"Descriptor should have 2 branches (receive and change), "
f"got {parsed_desc.num_branches}"
)
Loading