Skip to content

Commit 73bfd1f

Browse files
committed
test: add Issue 21 external signer E2E flow coverage
1 parent b5378e6 commit 73bfd1f

1 file changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""End-to-end QML test for external signer PSBT workflow behavior.
6+
7+
Coverage:
8+
1. External signer detection surfaces missing/multiple/error/available states.
9+
2. Failure -> retry transitions are recoverable via the refresh action.
10+
3. Externally signed PSBT import/broadcast path is validated in the GUI.
11+
"""
12+
13+
import argparse
14+
import os
15+
import re
16+
import sys
17+
import tempfile
18+
from decimal import Decimal
19+
from pathlib import Path
20+
from urllib.parse import quote
21+
22+
from qml_test_harness import QmlTestHarness, StepScreenshotRecorder, dump_qml_tree
23+
24+
REPO_ROOT = Path(__file__).resolve().parents[2]
25+
BITCOIN_FUNCTIONAL_PATH = REPO_ROOT / "bitcoin" / "test" / "functional"
26+
if str(BITCOIN_FUNCTIONAL_PATH) not in sys.path:
27+
sys.path.insert(0, str(BITCOIN_FUNCTIONAL_PATH))
28+
29+
from test_framework.authproxy import AuthServiceProxy, JSONRPCException
30+
from test_framework.util import wait_until_helper_internal
31+
32+
33+
WALLET_NAME = "qml_external_signer_wallet"
34+
RECEIVER_WALLET_NAME = "qml_external_signer_receiver"
35+
SEND_NOTE = "qml-external-signer-e2e"
36+
SEND_AMOUNT_BTC = Decimal("0.00200000")
37+
38+
39+
MOCK_SIGNER_SCRIPT = """#!/usr/bin/env python3
40+
import argparse
41+
import json
42+
import pathlib
43+
import sys
44+
45+
46+
def read_state(path):
47+
try:
48+
return pathlib.Path(path).read_text(encoding=\"utf8\").strip()
49+
except Exception:
50+
return \"available\"
51+
52+
53+
def enumerate_signers(args):
54+
state = read_state(args.state_file)
55+
if state == \"missing\":
56+
payload = []
57+
elif state == \"multiple\":
58+
payload = [
59+
{\"fingerprint\": \"00000001\", \"type\": \"trezor\", \"model\": \"Mock Trezor\"},
60+
{\"fingerprint\": \"00000002\", \"type\": \"ledger\", \"model\": \"Mock Ledger\"},
61+
]
62+
elif state == \"error\":
63+
payload = [{\"error\": \"Mock signer command failed\"}]
64+
else:
65+
payload = [{\"fingerprint\": \"00000001\", \"type\": \"trezor\", \"model\": \"Mock Trezor\"}]
66+
sys.stdout.write(json.dumps(payload))
67+
68+
69+
def getdescriptors(args):
70+
# This test only validates detect/import/broadcast UX; descriptor payload is
71+
# intentionally minimal.
72+
sys.stdout.write(json.dumps({\"receive\": [], \"internal\": []}))
73+
74+
75+
def signtx(args):
76+
# Echo PSBT unchanged in this mock. The GUI flow under test imports a PSBT
77+
# signed through RPC-side wallet tooling.
78+
sys.stdout.write(json.dumps({\"psbt\": args.psbt, \"complete\": False}))
79+
80+
81+
parser = argparse.ArgumentParser(description=\"QML external-signer mock\")
82+
parser.add_argument(\"--fingerprint\")
83+
parser.add_argument(\"--chain\", default=\"main\")
84+
parser.add_argument(\"--stdin\", action=\"store_true\")
85+
parser.add_argument(\"--state-file\", required=True)
86+
87+
subparsers = parser.add_subparsers(dest=\"command\")
88+
subparsers.required = True
89+
90+
p_enum = subparsers.add_parser(\"enumerate\")
91+
p_enum.set_defaults(func=enumerate_signers)
92+
93+
p_desc = subparsers.add_parser(\"getdescriptors\")
94+
p_desc.add_argument(\"--account\", metavar=\"account\")
95+
p_desc.set_defaults(func=getdescriptors)
96+
97+
p_sign = subparsers.add_parser(\"signtx\")
98+
p_sign.add_argument(\"psbt\", metavar=\"psbt\")
99+
p_sign.set_defaults(func=signtx)
100+
101+
if not sys.stdin.isatty():
102+
buffer = sys.stdin.read()
103+
if buffer and buffer.rstrip() != \"\":
104+
sys.argv.extend(buffer.rstrip().split(\" \"))
105+
106+
args = parser.parse_args()
107+
args.func(args)
108+
"""
109+
110+
111+
def parse_args():
112+
parser = argparse.ArgumentParser(description="QML external signer flow test")
113+
parser.add_argument(
114+
"--socket-path",
115+
help="Attach to an existing bridge socket. Not supported by this test.",
116+
)
117+
parser.add_argument(
118+
"--screenshot-dir",
119+
help="If set, save step screenshots (PNG) into this directory.",
120+
)
121+
return parser.parse_args()
122+
123+
124+
def build_rpc_url(user, password, port, wallet_name=None):
125+
auth = f"{quote(user, safe='')}:{quote(password, safe='')}"
126+
base = f"http://{auth}@127.0.0.1:{port}"
127+
if wallet_name is None:
128+
return base
129+
return f"{base}/wallet/{quote(wallet_name, safe='')}"
130+
131+
132+
def read_cookie(datadir):
133+
cookie_path = Path(datadir) / "regtest" / ".cookie"
134+
wait_until_helper_internal(lambda: cookie_path.is_file(), timeout=60)
135+
cookie = cookie_path.read_text(encoding="utf8").strip()
136+
user, password = cookie.split(":", 1)
137+
return user, password
138+
139+
140+
def connect_rpc(datadir, rpc_port):
141+
user, password = read_cookie(datadir)
142+
rpc = AuthServiceProxy(build_rpc_url(user, password, rpc_port), timeout=120)
143+
144+
def rpc_ready():
145+
try:
146+
return rpc.getblockchaininfo()["chain"] == "regtest"
147+
except Exception:
148+
return False
149+
150+
wait_until_helper_internal(rpc_ready, timeout=60)
151+
152+
wallet_rpc = AuthServiceProxy(
153+
build_rpc_url(user, password, rpc_port, WALLET_NAME), timeout=120
154+
)
155+
receiver_wallet_rpc = AuthServiceProxy(
156+
build_rpc_url(user, password, rpc_port, RECEIVER_WALLET_NAME), timeout=120
157+
)
158+
return rpc, wallet_rpc, receiver_wallet_rpc
159+
160+
161+
def ensure_wallet_loaded(rpc, wallet_name):
162+
try:
163+
rpc.createwallet(wallet_name)
164+
return
165+
except JSONRPCException as e:
166+
if "already exists" not in str(e).lower():
167+
raise
168+
try:
169+
rpc.loadwallet(wallet_name)
170+
except JSONRPCException as e:
171+
if "already loaded" not in str(e).lower():
172+
raise
173+
174+
175+
def wait_for_gui_wallet_ready(gui):
176+
gui.wait_for_property("walletBadge", "loading", timeout_ms=30000, value=False)
177+
gui.wait_for_property("walletBadge", "noWalletLoaded", timeout_ms=30000, value=False)
178+
gui.wait_for_property("walletBadge", "text", timeout_ms=30000, contains=WALLET_NAME)
179+
180+
181+
def select_wallet_tab(gui, tab_object_name, expected_page):
182+
gui.click(tab_object_name)
183+
gui.wait_for_page(expected_page, timeout_ms=15000)
184+
185+
186+
def send_amount_input_value(gui):
187+
amount_max_length = int(gui.get_property("sendAmountInput", "maximumLength"))
188+
if amount_max_length >= 17:
189+
return str(SEND_AMOUNT_BTC)
190+
return str(int(SEND_AMOUNT_BTC * Decimal("100000000")))
191+
192+
193+
def prepare_send_review(gui, destination, amount_input, note):
194+
select_wallet_tab(gui, "walletSendTab", "walletSendPage")
195+
gui.set_text("sendAddressInput", destination)
196+
gui.set_text("sendAmountInput", amount_input)
197+
gui.set_text("sendNoteInput", note)
198+
gui.wait_for_property("sendContinueButton", "enabled", timeout_ms=15000, value=True)
199+
gui.click("sendContinueButton")
200+
gui.wait_for_page("walletSendReviewPage", timeout_ms=15000)
201+
202+
203+
def open_psbt_operations_page(gui):
204+
gui.wait_for_page("walletSendPage", timeout_ms=15000)
205+
gui.click("sendOptionsMenuButton")
206+
gui.wait_for_property("sendOptionsPopup", "opened", timeout_ms=10000, value=True)
207+
gui.click("sendOptionsPsbtOperationsButton")
208+
gui.wait_for_page("psbtOperationsPage", timeout_ms=15000)
209+
210+
211+
def extract_txid(text):
212+
match = re.search(r"([0-9a-fA-F]{64})", str(text))
213+
if not match:
214+
raise AssertionError(f"Could not parse txid from: {text!r}")
215+
return match.group(1).lower()
216+
217+
218+
def write_signer_state(path, state):
219+
Path(path).write_text(state, encoding="utf8")
220+
221+
222+
def run_tests():
223+
args = parse_args()
224+
if args.socket_path:
225+
raise AssertionError(
226+
"--socket-path is not supported for this test; it requires harness-managed RPC/datadir."
227+
)
228+
229+
gui = None
230+
harness = None
231+
try:
232+
with tempfile.TemporaryDirectory(prefix="qml_external_signer_mock_") as signer_tmp:
233+
signer_script_path = Path(signer_tmp) / "switchable_signer.py"
234+
signer_state_path = Path(signer_tmp) / "signer_state.txt"
235+
signer_script_path.write_text(MOCK_SIGNER_SCRIPT, encoding="utf8")
236+
os.chmod(signer_script_path, 0o755)
237+
write_signer_state(signer_state_path, "missing")
238+
239+
signer_command = f"{sys.executable} {signer_script_path} --state-file={signer_state_path}"
240+
harness = QmlTestHarness(
241+
skip_onboard=True,
242+
extra_args=["-fallbackfee=0.00001000", f"-signer={signer_command}"],
243+
)
244+
harness.start()
245+
gui = harness.driver
246+
shots = StepScreenshotRecorder(gui, args.screenshot_dir)
247+
248+
rpc, wallet_rpc, receiver_wallet_rpc = connect_rpc(harness.datadir, harness.rpc_port)
249+
ensure_wallet_loaded(rpc, RECEIVER_WALLET_NAME)
250+
ensure_wallet_loaded(rpc, WALLET_NAME)
251+
wait_for_gui_wallet_ready(gui)
252+
shots.capture("wallet_ready")
253+
254+
funding_addr = wallet_rpc.getnewaddress("qml-external-funding", "bech32")
255+
rpc.generatetoaddress(101, funding_addr)
256+
257+
destination = receiver_wallet_rpc.getnewaddress("qml-external-destination", "bech32")
258+
amount_input = send_amount_input_value(gui)
259+
260+
# Build unsigned PSBT from send review.
261+
prepare_send_review(gui, destination, amount_input, SEND_NOTE)
262+
gui.click("sendReviewCopyPsbtButton")
263+
gui.wait_for_property(
264+
"sendReviewPsbtStatusText", "text", timeout_ms=15000, contains="copied"
265+
)
266+
unsigned_psbt = gui.get_clipboard_text().strip()
267+
assert unsigned_psbt.startswith("cHNidP"), "Unsigned PSBT copy did not produce base64 payload"
268+
shots.capture("unsigned_psbt_copied")
269+
270+
# Simulate external device signing outside GUI.
271+
signed_result = wallet_rpc.walletprocesspsbt(
272+
psbt=unsigned_psbt, sign=True, sighashtype="ALL", bip32derivs=True
273+
)
274+
signed_psbt = signed_result["psbt"]
275+
assert signed_psbt.startswith("cHNidP"), "Externally signed PSBT payload missing"
276+
277+
gui.click("sendReviewBackButton")
278+
gui.wait_for_page("walletSendPage", timeout_ms=15000)
279+
open_psbt_operations_page(gui)
280+
281+
# Detection state: missing (initial state file value).
282+
missing_status = gui.wait_for_property(
283+
"psbtExternalSignerStatusText",
284+
"text",
285+
timeout_ms=15000,
286+
contains="No external signer detected",
287+
)
288+
assert "no external signer" in str(missing_status).lower(), missing_status
289+
290+
# Failure/retry branch: multiple -> error -> available.
291+
write_signer_state(signer_state_path, "multiple")
292+
gui.click("psbtRefreshSignersButton")
293+
multiple_status = gui.wait_for_property(
294+
"psbtExternalSignerStatusText",
295+
"text",
296+
timeout_ms=15000,
297+
contains="Multiple external signers detected",
298+
)
299+
assert "multiple external signers" in str(multiple_status).lower(), multiple_status
300+
301+
write_signer_state(signer_state_path, "error")
302+
gui.click("psbtRefreshSignersButton")
303+
error_status = gui.wait_for_property(
304+
"psbtExternalSignerStatusText",
305+
"text",
306+
timeout_ms=15000,
307+
contains="Unable to detect external signer",
308+
)
309+
assert "unable to detect external signer" in str(error_status).lower(), error_status
310+
311+
write_signer_state(signer_state_path, "available")
312+
gui.click("psbtRefreshSignersButton")
313+
available_status = gui.wait_for_property(
314+
"psbtExternalSignerStatusText",
315+
"text",
316+
timeout_ms=15000,
317+
contains="External signer detected",
318+
)
319+
assert "mock trezor" in str(available_status).lower(), available_status
320+
shots.capture("external_signer_recovered_available")
321+
322+
# Import externally signed PSBT and broadcast through GUI.
323+
gui.set_clipboard_text(signed_psbt)
324+
gui.click("psbtImportClipboardButton")
325+
gui.wait_for_property("psbtStatusText", "text", timeout_ms=15000, contains="PSBT loaded")
326+
327+
if not bool(gui.get_property("psbtBroadcastButton", "enabled")):
328+
if bool(gui.get_property("psbtSignButton", "enabled")):
329+
gui.click("psbtSignButton")
330+
if not bool(gui.get_property("psbtBroadcastButton", "enabled")) and bool(
331+
gui.get_property("psbtFinalizeButton", "enabled")
332+
):
333+
gui.click("psbtFinalizeButton")
334+
335+
gui.wait_for_property("psbtBroadcastButton", "enabled", timeout_ms=15000, value=True)
336+
gui.click("psbtBroadcastButton")
337+
txid_label = gui.wait_for_property(
338+
"psbtBroadcastTxidText", "text", timeout_ms=15000, non_empty=True
339+
)
340+
broadcast_txid = extract_txid(txid_label)
341+
wait_until_helper_internal(lambda: broadcast_txid in rpc.getrawmempool(), timeout=30)
342+
shots.capture("external_signer_psbt_broadcasted")
343+
344+
print("\n" + "=" * 60)
345+
print("QML external signer flow E2E PASSED")
346+
print(f"Broadcast txid: {broadcast_txid}")
347+
print("=" * 60)
348+
349+
except Exception as e:
350+
print(f"\nFAILED: {e}", file=sys.stderr)
351+
import traceback
352+
353+
traceback.print_exc()
354+
if gui is not None:
355+
dump_qml_tree(gui)
356+
sys.exit(1)
357+
finally:
358+
if harness is not None:
359+
harness.stop()
360+
361+
362+
if __name__ == "__main__":
363+
run_tests()

0 commit comments

Comments
 (0)