fix: retry wallet import with widened range on shrink-refusal (#2604) (#2666)
Some checks failed
Build Docker container on push / Build image (push) Has been cancelled
Electron smoketest / smoketest (push) Has been cancelled
Extension Compatibility Check / extension-compat (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / cypress (push) Has been cancelled
Tests / extension-smoketest (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Black Python Linter / black (push) Has been cancelled

Co-authored-by: k9ert <117085+k9ert@users.noreply.github.com>
This commit is contained in:
ack-chai 2026-08-09 02:04:48 +05:30 committed by GitHub
parent bf0780d6bc
commit 8843e2f064
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 211 additions and 0 deletions

View file

@ -354,6 +354,40 @@ class Wallet(AbstractWallet):
res = wallet_rpc.importdescriptors(args)
else:
res = wallet_rpc.importmulti(args, {"rescan": False})
if not all([r["success"] for r in res]):
# Core refuses to shrink an existing keypool range on
# (re-)import: "new range must include current range".
# This can happen when re-creating a wallet under a name
# that Core already has a wider range recorded for (e.g.
# after a keypoolrefill). Parse the range Core is telling
# us about and retry once with a range wide enough to
# include it (see #2604).
widened_range = None
for r in res:
if r["success"]:
continue
match = re.search(
r"current range = \[(\d+),\s*(\d+)\]",
r.get("error", {}).get("message", ""),
)
if not match:
continue
current_start, current_end = int(match.group(1)), int(
match.group(2)
)
start = min(0, current_start)
end = max(cls.GAP_LIMIT, current_end)
if widened_range is None:
widened_range = [start, end]
else:
widened_range = [
min(widened_range[0], start),
max(widened_range[1], end),
]
if widened_range is not None:
for arg in args:
arg["range"] = widened_range
res = wallet_rpc.importmulti(args, {"rescan": False})
if not all([r["success"] for r in res]):
all_issues = " and ".join(

View file

@ -0,0 +1,177 @@
"""
Regression tests for https://github.com/cryptoadvance/specter-desktop/issues/2604
Bitcoin Core's legacy `importmulti` refuses to shrink the keypool range of an
already-imported descriptor ("new range must include current range"). This can
surface when re-creating a wallet under a name that Core already has a wider
range recorded for. Wallet.create should detect that specific rejection and
retry once with a range wide enough to include Core's current range, instead
of failing outright.
These tests mock the RPC layer entirely, so they don't need a running
bitcoind/regtest node.
"""
from unittest.mock import MagicMock
from cryptoadvance.specter.key import Key
from cryptoadvance.specter.wallet import Wallet
def _make_rpc(importmulti_responses):
"""Builds a fake `rpc`/`wallet_rpc` pair.
importmulti_responses: a list of return values, one per call to
`importmulti`. Each element is itself the list-of-dicts the real RPC
would return.
"""
wallet_rpc = MagicMock()
wallet_rpc.importmulti.side_effect = importmulti_responses
rpc = MagicMock()
rpc.getnetworkinfo.return_value = {"version": 200000} # pre-descriptor-wallets
rpc.wallet.return_value = wallet_rpc
return rpc, wallet_rpc
class _BareWallet(Wallet):
"""A stand-in for Wallet that skips the real __init__ (which touches
rpc, address lists, tx history, etc.) so these tests only exercise the
`create()` classmethod's import/retry logic."""
def __init__(self, *args, **kwargs):
self.init_args = args
self.init_kwargs = kwargs
def _single_sig_key():
# A known-good testnet tpub (m/84'/1'/0') used elsewhere in the test
# suite's ghost-machine fixtures.
tpub = "tpubDC4DsqH5rqHqipMNqUbDFtQT3AkKkUrvLsN6miySvortU3s1LGaNVAb7wX2No2VsuxQV82T8s3HJLv3kdx1CPjsJ3onC1Zo5mWCQzRVaWVX"
return Key.parse_xpub(f"[81f802e3/84h/1h/0h]{tpub}")
def test_create_retries_with_widened_range_on_shrink_refusal():
"""Core rejects the default [0, GAP_LIMIT] range because it already has
a wider range on file; Wallet.create should retry with a widened range
and succeed."""
key = _single_sig_key()
shrink_refusal = [
{
"success": False,
"error": {"message": "new range must include current range = [0,1008]"},
},
{
"success": False,
"error": {"message": "new range must include current range = [0,1000]"},
},
]
success = [{"success": True}, {"success": True}]
rpc, wallet_rpc = _make_rpc([shrink_refusal, success])
wallet = _BareWallet.create(
rpc=rpc,
rpc_path="specter",
working_folder="/tmp",
device_manager=MagicMock(),
wallet_manager=MagicMock(),
name="mywallet",
alias="mywallet",
sigs_required=1,
key_type="wpkh",
keys=[key],
devices=[MagicMock()],
core_version=200000,
)
assert wallet is not None
# One retry: the first call (default range) failed, the second (widened
# range) succeeded.
assert wallet_rpc.importmulti.call_count == 2
second_call_args = wallet_rpc.importmulti.call_args_list[1][0][0]
# Widened range must cover the widest range Core reported (1008)
assert all(arg["range"] == [0, 1008] for arg in second_call_args)
def test_create_raises_if_retry_also_fails():
"""If the widened-range retry still fails, Wallet.create should still
raise a SpecterError (no silent swallow of a real problem)."""
from cryptoadvance.specter.specter_error import SpecterError
key = _single_sig_key()
shrink_refusal = [
{
"success": False,
"error": {"message": "new range must include current range = [0,1008]"},
},
]
still_failing = [
{
"success": False,
"error": {"message": "some other unrelated error"},
},
]
rpc, wallet_rpc = _make_rpc([shrink_refusal, still_failing])
try:
_BareWallet.create(
rpc=rpc,
rpc_path="specter",
working_folder="/tmp",
device_manager=MagicMock(),
wallet_manager=MagicMock(),
name="mywallet",
alias="mywallet",
sigs_required=1,
key_type="wpkh",
keys=[key],
devices=[MagicMock()],
core_version=200000,
)
assert False, "expected SpecterError"
except SpecterError:
pass
assert wallet_rpc.importmulti.call_count == 2
def test_create_does_not_retry_on_unrelated_failure():
"""A failure that isn't the range-shrink message should raise immediately,
without a pointless retry."""
from cryptoadvance.specter.specter_error import SpecterError
key = _single_sig_key()
unrelated_failure = [
{
"success": False,
"error": {"message": "some other unrelated error"},
},
]
rpc, wallet_rpc = _make_rpc([unrelated_failure])
try:
_BareWallet.create(
rpc=rpc,
rpc_path="specter",
working_folder="/tmp",
device_manager=MagicMock(),
wallet_manager=MagicMock(),
name="mywallet",
alias="mywallet",
sigs_required=1,
key_type="wpkh",
keys=[key],
devices=[MagicMock()],
core_version=200000,
)
assert False, "expected SpecterError"
except SpecterError:
pass
assert wallet_rpc.importmulti.call_count == 1