Compare commits

...

7 commits

Author SHA1 Message Date
al-munazzim
3bcbae1a32
Pin Werkzeug below 3.1 for Flask 2.2 compatibility (#2699)
Some checks are pending
Build Docker container on push / Build image (push) Waiting to run
Extension Compatibility Check / extension-compat (push) Waiting to run
Tests / test (push) Waiting to run
Tests / cypress (push) Waiting to run
Tests / extension-smoketest (push) Waiting to run
TOC Generator / TOC Generator (push) Waiting to run
Black Python Linter / black (push) Waiting to run
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nazim <nazim@openclaw.ai>
2026-08-12 21:53:45 +02:00
al-munazzim
180801223d
Fix REST auth errors for Flask-HTTPAuth 4.8.1 (#2697)
Some checks are pending
Build Docker container on push / Build image (push) Waiting to run
Extension Compatibility Check / extension-compat (push) Waiting to run
Tests / test (push) Waiting to run
Tests / cypress (push) Waiting to run
Tests / extension-smoketest (push) Waiting to run
TOC Generator / TOC Generator (push) Waiting to run
Black Python Linter / black (push) Waiting to run
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nazim <nazim@openclaw.ai>
2026-08-12 12:17:08 +02:00
al-munazzim
07eb1520dc
Chore(deps): bundle low-risk Dependabot updates (#2696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nazim <nazim@openclaw.ai>
2026-08-12 09:41:11 +02:00
al-munazzim
f814ac47c5
Chore(deps): bundle 5 low-risk Dependabot updates (#2693)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nazim <nazim@openclaw.ai>
Co-authored-by: k9ert <117085+k9ert@users.noreply.github.com>
2026-08-11 21:48:44 +02:00
al-munazzim
69a43ef1ea
Fix PyInstaller specterd backports import (#2694)
Co-authored-by: Nazim <nazim@openclaw.ai>
2026-08-11 21:19:55 +02:00
al-munazzim
6755046e4b
chore: bump Cypress to 13.17.0 (#2646)
Co-authored-by: Nazim <nazim@openclaw.ai>
2026-08-11 19:14:45 +02:00
ack-chai
8843e2f064
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>
2026-08-08 22:34:48 +02:00
19 changed files with 942 additions and 693 deletions

40
cypress.config.js Normal file
View file

@ -0,0 +1,40 @@
const { defineConfig } = require('cypress')
const setupNodeEvents = require('./cypress/plugins')
const specPattern = [
'cypress/integration/spec_empty_specter_home.js',
'cypress/integration/spec_connections.js',
'cypress/integration/spec_devices.js',
'cypress/integration/spec_ghost_machine.js',
'cypress/integration/spec_fees.js',
'cypress/integration/spec_rescan.js',
'cypress/integration/spec_qr_signing.js',
'cypress/integration/spec_labeling.js',
'cypress/integration/spec_balances_amounts.js',
'cypress/integration/spec_wallet_send.js',
'cypress/integration/spec_wallet_utxo.js',
'cypress/integration/spec_plugins.js',
]
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:25444',
specPattern,
supportFile: 'cypress/support/index.js',
testIsolation: false,
setupNodeEvents,
},
env: {
broadcast_timeout: 8000,
},
includeShadowDom: false,
reporter: 'junit',
reporterOptions: {
mochaFile: 'cypresstest-output.xml',
toConsole: true,
},
retries: {
runMode: 1,
openMode: 0,
},
})

View file

@ -29,8 +29,15 @@ describe('Completely empty specter-home', () => {
})
it('Login with password and deactivate password protection again', () => {
cy.get('[data-cy="admin-password"]').type('satoshi')
cy.get('[data-cy="login-btn"]').click()
cy.get('body').then(($body) => {
// Cypress 13 keeps browser context when testIsolation is disabled for this
// stateful suite. If the session cookie survived from the previous test,
// we are already logged in and can go straight to settings.
if ($body.find('[data-cy="admin-password"]').length) {
cy.get('[data-cy="admin-password"]').type('satoshi')
cy.get('[data-cy="login-btn"]').click()
}
})
cy.get('[data-cy="settings-btn"]').click()
cy.contains('Authentication').click()
cy.get('[data-cy="authentication-selection"]').select('None')

View file

@ -152,7 +152,7 @@ describe('Test the fee UI', () => {
cy.intercept('POST', '/wallets/wallet/ghost_wallet/estimate_fee', {fee: 0.00005944})
cy.get('[data-cy="show-estimated-fee-btn"]').click()
cy.contains('You provided no address')
cy.get('#recipient_0').find('#address').invoke('val', "bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs")
cy.get('#recipient_0').find('#address').invoke('val', "bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs").trigger('input')
cy.get('[data-cy="sats-checkbox"]').click()
cy.get('#recipient_0').find('[data-cy="amount-input"]').type(50000, { force: true })
cy.get('[data-cy="show-estimated-fee-btn"]').click()

View file

@ -5,10 +5,8 @@ describe('Test the labeling of addresses and transactions', () => {
cy.visit('/')
})
// Keeps the session cookie alive, Cypress by default clears all cookies before each test
beforeEach(() => {
cy.viewport('macbook-13')
Cypress.Cookies.preserveOnce('session')
})
it('Labeling an address on the address overview', () => {

View file

@ -26,9 +26,16 @@ describe('Test plugins', () => {
})
it('Deactivate password protection', () => {
// This flow only works if we don't keep the session alive! So, no Cypress.Cookies.preserveOnce('session') in beforeEach().
cy.get('#password').type("mySecretPassword")
cy.get('#login-btn').click()
cy.get('body').then(($body) => {
// Cypress 13 keeps browser context when testIsolation is disabled for
// this stateful suite. If the session cookie survived from the
// previous test, we are already logged in and can go straight to
// settings; otherwise log in first.
if ($body.find('#password').length) {
cy.get('#password').type("mySecretPassword")
cy.get('#login-btn').click()
}
})
cy.get('[data-cy="settings-btn"]').click()
cy.get('[href="/settings/auth"]').click()
cy.get('select').select("none")

View file

@ -22,7 +22,6 @@ import './commands'
// Set the view port for each test
beforeEach(() => {
cy.viewport('macbook-13')
cy.session('preserveSession', () => {}) // Keeps the session cookie alive, Cypress by default clears all cookies before each test
cy.visit('/')
})

707
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
"wait-on": "^9.0.5"
},
"devDependencies": {
"cypress": "^9.7.0",
"cypress": "^13.17.0",
"cypress-wait-until": "^1.7.1",
"gulp": "^5.0.1",
"tailwindcss": "^3.2.4"

View file

@ -7,6 +7,7 @@
pyinstaller==6.11.1
pyinstaller-hooks-contrib>=2024.0
setuptools>=78.1.1
backports.tarfile==1.2.0
# Platform helpers
altgraph>=0.17

View file

@ -76,17 +76,17 @@ dependencies = {file = ["requirements.in"]}
[project.optional-dependencies]
test = [
"black==22.3.0",
"black==26.3.1",
"pre-commit==2.13.0",
"pip-tools==6.13",
"pytest==7.1.2",
"pytest==9.0.3",
"PySocks==1.7.1",
"pytest-cov==2.10.1",
"mock==4.0.2",
# requirements for stuff in ./utils
"requests==2.31.0",
"requests==2.33.0",
]
gendownloadpage = [
"markdown==3.3.7"
"markdown==3.8.1"
]

View file

@ -1,14 +1,16 @@
certifi==2024.7.4
Click==8.1.7
Flask==2.2.5
# Flask 2.2.x test_client uses werkzeug.__version__, removed in Werkzeug 3.1.
Werkzeug==3.0.6
Flask-Babel==3.1.0
Flask-Cors==6.0.0
Flask-Login==0.6.3
Flask-RESTful==0.3.10
Flask-HTTPAuth==4.8.0
Flask-HTTPAuth==4.8.1
hwi==3.1.0
python-dotenv==0.21.1
requests==2.31.0
python-dotenv==1.2.2
requests==2.33.0
pysocks==1.7.1
six==1.16.0
stem==1.8.0

View file

@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --cert=None --client-cert=None --generate-hashes --index-url=None --pip-args=None requirements.in
# pip-compile --generate-hashes requirements.in
#
aioitertools==0.11.0 \
--hash=sha256:04b95e3dab25b449def24d7df809411c10e62aab0cbe31a50ca4e68748c43394 \
@ -267,9 +267,9 @@ flask-cors==6.0.0 \
--hash=sha256:4592c1570246bf7beee96b74bc0adbbfcb1b0318f6ba05c412e8909eceec3393 \
--hash=sha256:6332073356452343a8ccddbfec7befdc3fdd040141fe776ec9b94c262f058657
# via -r requirements.in
flask-httpauth==4.8.0 \
--hash=sha256:66568a05bc73942c65f1e2201ae746295816dc009edd84b482c44c758d75097a \
--hash=sha256:a58fedd09989b9975448eef04806b096a3964a7feeebc0a78831ff55685b62b0
flask-httpauth==4.8.1 \
--hash=sha256:0080393d70e12327781f7509115175ec5e47209816489a620d4fd39e20cea2e8 \
--hash=sha256:88499b22f1353893743c3cd68f2ca561c4ad9ef75cd6bcc7f621161cd0e80744
# via -r requirements.in
flask-login==0.6.3 \
--hash=sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333 \
@ -744,9 +744,9 @@ psycopg2-binary==2.9.5 \
pyaes==1.6.1 \
--hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f
# via hwi
pyasn1==0.6.3 \
--hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \
--hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde
pyasn1==0.6.4 \
--hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \
--hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b
# via pgpy
pycparser==2.22 \
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
@ -777,9 +777,9 @@ python-dateutil==2.9.0.post0 \
# via
# flask-apscheduler
# pandas
python-dotenv==0.21.1 \
--hash=sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49 \
--hash=sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via -r requirements.in
pytimeparse==1.1.8 \
--hash=sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd \
@ -793,9 +793,9 @@ pytz==2024.1 \
# flask-babel
# flask-restful
# pandas
requests==2.31.0 \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
requests==2.33.0 \
--hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \
--hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652
# via
# -r requirements.in
# cryptoadvance-spectrum
@ -907,10 +907,11 @@ urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via requests
werkzeug==3.0.3 \
--hash=sha256:097e5bfda9f0aba8da6b8545146def481d06aa7d3266e7448e2cccf67dd8bd18 \
--hash=sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8
werkzeug==3.0.6 \
--hash=sha256:1bc0c2310d2fbb07b1dd1105eba2f7af72f322e1e455f2f93c993bee8c8a5f17 \
--hash=sha256:a8dd59d4de28ca70471a34cba79bed5f7ef2e036a76b3ab0835474246eb41f8d
# via
# -r requirements.in
# flask
# flask-cors
# flask-login

View file

@ -1,4 +1,5 @@
""" Security Related things for the REST-API """
"""Security Related things for the REST-API"""
import logging, jwt
from functools import wraps
@ -14,6 +15,18 @@ from flask import current_app as app
logger = logging.getLogger(__name__)
@auth.error_handler
@token_auth.error_handler
def auth_error(status):
"""Return REST API authentication failures as JSON.
Flask-HTTPAuth 4.8.1 changed the default unauthorized response body to
plain text ("Unauthorized Access"). The REST API has historically returned
Flask-RESTful JSON error objects, and clients/tests rely on that shape.
"""
return abort(status)
@auth.verify_password
def verify_password(username, password):
"""Validate user passwords and store user in the 'g' object"""

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

View file

@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
@click.option("--delimiter", default=",", help="the delimiter")
@click.argument("spec_file")
def execute(debug, run, delimiter, spec_file):
with open("cypress.json") as json_file:
with open("cypress-tests.json") as json_file:
data = json.load(json_file)
spec_create_list = []

View file

@ -240,7 +240,7 @@ function restore_snapshot {
# Checking whether spec-files exists
if ! [ -f ./cypress/integration/${spec_file} ]; then
echo "Spec-file $spec_file does not exist, these are the options:"
cat cypress.json | jq -r ".testFiles[]"
cat cypress-tests.json | jq -r ".testFiles[]"
exit 1
fi
snapshot_file=./cypress/fixtures/${spec_file}_btcdir.tar.gz
@ -341,7 +341,11 @@ function sub_run {
start_bitcoind --reset
start_elementsd --reset
start_specter --reset
npx cypress run
# Cypress 10+ normalizes specs discovered from config (often alphabetically),
# but this suite is intentionally stateful and depends on the order in
# cypress-tests.json. Pass an explicit --spec list so a full run keeps the
# legacy order from cypress.json/testFiles.
npx cypress run --spec "$(./utils/calc_cypress_test_spec.py --run spec_empty_specter_home.js)"
fi
}
@ -358,7 +362,7 @@ function sub_snapshot {
# We'll create a snapshot BEFORE this spec-file has been tested:
if [ ! -f ./cypress/integration/$spec_file ]; then
echo "ERROR: Use one of these arguments:"
cat cypress.json | jq -r ".testFiles[]"
cat cypress-tests.json | jq -r ".testFiles[]"
exit 2
fi
start_bitcoind --reset

569
yarn.lock

File diff suppressed because it is too large Load diff