Skip to content

Commit b5378e6

Browse files
committed
Issue 21: add external signer detection surface to PSBT flow
1 parent 47d423d commit b5378e6

File tree

6 files changed

+266
-2
lines changed

6 files changed

+266
-2
lines changed

doc/test-automation-selectors.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ It supports the parity backlog Definition of Done (`DefinitionOfDone.md`) requir
2323

2424
- Receive flow: `receiveAmountInput`, `receiveLabelInput`, `receiveContactSelectButton`, `receiveContactsPopup`, `receiveContactsSearchInput`, `receiveContactRow`, `receiveContactSelectFirstActionButton`, `receiveContactUseButton`, `receiveCreateAddressButton`, `receiveCopySelectedUriButton`, `receiveQrImage`
2525
- Send flow: `sendAddressInput`, `sendOpenContactsButton`, `sendContactsPopup`, `sendContactsSearchInput`, `sendContactLabelInput`, `sendContactAddressInput`, `sendContactSaveButton`, `sendContactDeleteButton`, `sendContactUseButton`, `sendContinueButton`, `sendPaymentRequestStatusText`, `sendPaymentRequestMessageText`, `sendOptionsPastePaymentRequestButton`, `sendOptionsOpenPaymentRequestButton`, `sendOptionsImportPaymentRequestFileButton`, `sendOptionsPsbtOperationsButton`, `sendUriImportPopup`, `sendUriImportInput`, `sendUriImportApplyButton`, `sendUriImportCancelButton`, `sendImportPaymentRequestFileDialog`, `sendPaymentRequestDropArea`, `sendPaymentRequestDropHint`, `sendDropUriInput`, `sendApplyDropUriButton`, `sendImportPaymentRequestFilePathInput`, `sendApplyPaymentRequestFilePathButton`, `sendReviewCopyPsbtButton`, `sendReviewSavePsbtButton`, `sendReviewSavePsbtFileDialog`, `sendReviewPsbtStatusText`, `multipleSendReviewCopyPsbtButton`, `multipleSendReviewSavePsbtButton`, `multipleSendReviewSavePsbtFileDialog`, `multipleSendReviewPsbtStatusText`, `sendResultPopup`
26-
- PSBT operations flow: `psbtOperationsPage`, `psbtOperationsBackButton`, `psbtImportClipboardButton`, `psbtImportFileButton`, `psbtImportFileDialog`, `psbtWorkflowStatusText`, `psbtInputSummaryText`, `psbtSignButton`, `psbtFinalizeButton`, `psbtBroadcastButton`, `psbtCopyButton`, `psbtSaveButton`, `psbtSaveFileDialog`, `psbtStatusText`, `psbtBroadcastTxidText`
26+
- PSBT operations flow: `psbtOperationsPage`, `psbtOperationsBackButton`, `psbtImportClipboardButton`, `psbtImportFileButton`, `psbtImportFileDialog`, `psbtExternalSignerPanel`, `psbtExternalSignerStatusText`, `psbtExternalSignerHintText`, `psbtExternalSignerNamesText`, `psbtRefreshSignersButton`, `psbtWorkflowStatusText`, `psbtInputSummaryText`, `psbtSignButton`, `psbtFinalizeButton`, `psbtBroadcastButton`, `psbtCopyButton`, `psbtSaveButton`, `psbtSaveFileDialog`, `psbtStatusText`, `psbtBroadcastTxidText`
2727
- Sign/verify message flow: `walletSelectSignMessageButton`, `walletSelectVerifyMessageButton`, `signVerifyMessagePage`, `signVerifyMessageBackButton`, `signVerifySignTab`, `signVerifyVerifyTab`, `signVerifySignAddressInput`, `signVerifySignAddressBookButton`, `signVerifySignPasteAddressButton`, `signVerifySignMessageInput`, `signVerifySignPassphraseInput`, `signVerifySignSignatureOutput`, `signVerifySignCopySignatureButton`, `signVerifySignButton`, `signVerifySignClearButton`, `signVerifySignStatusText`, `signVerifyVerifyAddressInput`, `signVerifyVerifyAddressBookButton`, `signVerifyVerifyMessageInput`, `signVerifyVerifySignatureInput`, `signVerifyVerifyButton`, `signVerifyVerifyClearButton`, `signVerifyVerifyStatusText`, `signVerifyAddressBookPopup`, `signVerifyAddressBookSearchInput`, `signVerifyAddressBookList`, `signVerifyAddressBookSelectFirstActionButton`, `signVerifyAddressBookUseButton`
2828
- Wallet tabs/pages: `walletSendPage`, `walletRequestPaymentPage`, `activityListView`, `activityOpenFirstRowActionButton`, `activityOpenRowAddressInput`, `activityOpenRowByAddressActionButton`, `activityDetailsPage`, `activityDetailsBackButton`
2929
- Activity RBF actions: `activityDetailsBumpButton`, `activityDetailsBumpPopup`, `activityDetailsBumpPreviewButton`, `activityDetailsBumpConfirmPopup`, `activityDetailsBumpConfirmButton`, `activityDetailsBumpDisabledReasonText`, `activityDetailsCancelButton`, `activityDetailsCancelPopup`, `activityDetailsCancelConfirmButton`, `activityDetailsRbfStatusText`, `activityDetailsReplacementTxidText`

qml/models/nodemodel.cpp

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515

1616
#include <cassert>
1717
#include <chrono>
18+
#include <exception>
1819

1920
#include <QDateTime>
2021
#include <QMetaObject>
21-
#include <QTimerEvent>
2222
#include <QString>
23+
#include <QTimerEvent>
24+
#include <QVariantList>
2325

2426
NodeModel::NodeModel(interfaces::Node& node)
2527
: m_node{node}
@@ -195,6 +197,55 @@ QString NodeModel::defaultProxyAddress()
195197
return QString::fromStdString(std::string(DEFAULT_PROXY_HOST) + ":" + util::ToString(DEFAULT_PROXY_PORT));
196198
}
197199

200+
QVariantMap NodeModel::listExternalSigners()
201+
{
202+
QVariantMap payload;
203+
try {
204+
const std::vector<std::unique_ptr<interfaces::ExternalSigner>> signers = m_node.listExternalSigners();
205+
QVariantList signer_names;
206+
signer_names.reserve(static_cast<qsizetype>(signers.size()));
207+
208+
for (const auto& signer : signers) {
209+
if (!signer) {
210+
continue;
211+
}
212+
signer_names.push_back(QString::fromStdString(signer->getName()));
213+
}
214+
215+
const int signer_count = signer_names.size();
216+
payload[QStringLiteral("success")] = true;
217+
payload[QStringLiteral("count")] = signer_count;
218+
payload[QStringLiteral("signers")] = signer_names;
219+
220+
if (signer_count < 1) {
221+
payload[QStringLiteral("state")] = QStringLiteral("missing");
222+
payload[QStringLiteral("message")] = tr("No external signer detected. Connect your device and retry.");
223+
} else if (signer_count == 1) {
224+
payload[QStringLiteral("state")] = QStringLiteral("available");
225+
payload[QStringLiteral("message")] = tr("External signer detected: %1").arg(signer_names.first().toString());
226+
} else {
227+
payload[QStringLiteral("state")] = QStringLiteral("multiple");
228+
payload[QStringLiteral("message")] = tr("Multiple external signers detected. Connect only one signer and retry.");
229+
}
230+
return payload;
231+
} catch (const std::exception& e) {
232+
payload[QStringLiteral("success")] = false;
233+
payload[QStringLiteral("state")] = QStringLiteral("error");
234+
payload[QStringLiteral("count")] = 0;
235+
payload[QStringLiteral("signers")] = QVariantList{};
236+
payload[QStringLiteral("message")] = tr("Unable to detect external signer: %1")
237+
.arg(QString::fromUtf8(e.what()));
238+
return payload;
239+
} catch (...) {
240+
payload[QStringLiteral("success")] = false;
241+
payload[QStringLiteral("state")] = QStringLiteral("error");
242+
payload[QStringLiteral("count")] = 0;
243+
payload[QStringLiteral("signers")] = QVariantList{};
244+
payload[QStringLiteral("message")] = tr("Unable to detect external signer due to an unknown error.");
245+
return payload;
246+
}
247+
}
248+
198249
QVariantMap NodeModel::broadcastSignedPsbt(const QString& psbt_base64)
199250
{
200251
QVariantMap payload;

qml/models/nodemodel.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class NodeModel : public QObject
6868

6969
Q_INVOKABLE bool validateProxyAddress(QString addr_port);
7070
Q_INVOKABLE QString defaultProxyAddress();
71+
Q_INVOKABLE QVariantMap listExternalSigners();
7172
Q_INVOKABLE QVariantMap broadcastSignedPsbt(const QString& psbt_base64);
7273

7374
public Q_SLOTS:

qml/pages/wallet/PsbtOperations.qml

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Page {
2727
property bool actionStatusError: false
2828
property string broadcastTxid: ""
2929

30+
property string externalSignerState: "unknown"
31+
property string externalSignerStatusText: ""
32+
property bool externalSignerStatusError: false
33+
property int externalSignerCount: 0
34+
property var externalSignerNames: []
35+
3036
function applyResult(result) {
3137
if (!result) {
3238
return
@@ -73,6 +79,36 @@ Page {
7379
applyResult(wallet.savePsbtToFile(psbtBase64, destination))
7480
}
7581

82+
function applyExternalSignerResult(result) {
83+
if (!result) {
84+
return
85+
}
86+
87+
externalSignerState = result.state || "unknown"
88+
externalSignerStatusText = result.message || ""
89+
externalSignerStatusError = !result.success
90+
91+
externalSignerNames = result.signers || []
92+
externalSignerCount = result.count !== undefined
93+
? result.count
94+
: externalSignerNames.length
95+
}
96+
97+
function refreshExternalSignerState() {
98+
if (!nodeModel || !nodeModel.listExternalSigners) {
99+
externalSignerState = "error"
100+
externalSignerStatusText = qsTr("External signer detection is unavailable in this build.")
101+
externalSignerStatusError = true
102+
externalSignerNames = []
103+
externalSignerCount = 0
104+
return
105+
}
106+
107+
applyExternalSignerResult(nodeModel.listExternalSigners())
108+
}
109+
110+
Component.onCompleted: refreshExternalSignerState()
111+
76112
header: NavigationBar2 {
77113
leftItem: NavButton {
78114
objectName: "psbtOperationsBackButton"
@@ -135,6 +171,75 @@ Page {
135171
font.pixelSize: 15
136172
}
137173

174+
Rectangle {
175+
objectName: "psbtExternalSignerPanel"
176+
Layout.fillWidth: true
177+
radius: 6
178+
border.width: 1
179+
border.color: Theme.color.neutral4
180+
color: Theme.color.neutral1
181+
implicitHeight: signerPanelColumn.implicitHeight + 20
182+
183+
ColumnLayout {
184+
id: signerPanelColumn
185+
anchors.fill: parent
186+
anchors.margins: 10
187+
spacing: 8
188+
189+
CoreText {
190+
Layout.fillWidth: true
191+
text: qsTr("External signer")
192+
font.pixelSize: 14
193+
bold: true
194+
color: Theme.color.neutral8
195+
}
196+
197+
CoreText {
198+
objectName: "psbtExternalSignerStatusText"
199+
Layout.fillWidth: true
200+
wrapMode: Text.Wrap
201+
text: root.externalSignerStatusText
202+
color: root.externalSignerStatusError ? Theme.color.red : Theme.color.neutral9
203+
}
204+
205+
CoreText {
206+
objectName: "psbtExternalSignerHintText"
207+
Layout.fillWidth: true
208+
wrapMode: Text.Wrap
209+
visible: text.length > 0
210+
text: {
211+
if (root.externalSignerState === "missing") {
212+
return qsTr("No signer is available. Connect a compatible device, then refresh detection. You can still copy or save the PSBT for external signing.")
213+
}
214+
if (root.externalSignerState === "multiple") {
215+
return qsTr("More than one signer was detected. Disconnect extra devices and keep one signer attached before retrying.")
216+
}
217+
if (root.externalSignerState === "available") {
218+
return qsTr("After signing on the device, import the updated PSBT to finalize and broadcast.")
219+
}
220+
return ""
221+
}
222+
color: Theme.color.neutral7
223+
}
224+
225+
CoreText {
226+
objectName: "psbtExternalSignerNamesText"
227+
Layout.fillWidth: true
228+
wrapMode: Text.Wrap
229+
visible: root.externalSignerCount > 0
230+
text: qsTr("Detected signer(s): %1").arg(root.externalSignerNames.join(", "))
231+
color: Theme.color.neutral8
232+
}
233+
234+
ContinueButton {
235+
objectName: "psbtRefreshSignersButton"
236+
Layout.alignment: Qt.AlignLeft
237+
text: qsTr("Refresh signers")
238+
onClicked: root.refreshExternalSignerState()
239+
}
240+
}
241+
}
242+
138243
RowLayout {
139244
Layout.fillWidth: true
140245
spacing: 10

test/qml/qml_tests_main.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2233,6 +2233,7 @@ class MockNodeModel : public QObject
22332233
Q_PROPERTY(bool faulted MEMBER m_faulted NOTIFY faultedChanged)
22342234
Q_PROPERTY(int blockTipHeight MEMBER m_block_tip_height NOTIFY blockTipHeightChanged)
22352235
Q_PROPERTY(int broadcastSignedPsbtCalls READ broadcastSignedPsbtCalls NOTIFY broadcastSignedPsbtCallsChanged)
2236+
Q_PROPERTY(int externalSignerQueryCalls READ externalSignerQueryCalls NOTIFY externalSignerQueryCallsChanged)
22362237

22372238
public:
22382239
bool m_pause{false};
@@ -2246,8 +2247,12 @@ class MockNodeModel : public QObject
22462247
QString m_next_broadcast_message{QStringLiteral("Transaction broadcast successfully! Transaction ID: mocktxid")};
22472248
QString m_next_broadcast_txid{QStringLiteral("mocktxid")};
22482249
QString m_last_broadcast_psbt;
2250+
bool m_external_signer_query_success{true};
2251+
QVariantList m_external_signers{QVariant{QStringLiteral("Mock External Signer")}};
2252+
QString m_external_signer_query_error;
22492253

22502254
int broadcastSignedPsbtCalls() const { return m_broadcast_signed_psbt_calls; }
2255+
int externalSignerQueryCalls() const { return m_external_signer_query_calls; }
22512256

22522257
Q_INVOKABLE void startNodeInitializionThread() {}
22532258
Q_INVOKABLE void requestShutdown() { Q_EMIT requestedShutdown(); }
@@ -2261,6 +2266,69 @@ class MockNodeModel : public QObject
22612266
return pattern.match(value).hasMatch();
22622267
}
22632268

2269+
Q_INVOKABLE QVariantMap listExternalSigners()
2270+
{
2271+
++m_external_signer_query_calls;
2272+
Q_EMIT externalSignerQueryCallsChanged();
2273+
2274+
QVariantMap payload;
2275+
payload.insert(QStringLiteral("success"), m_external_signer_query_success);
2276+
2277+
if (!m_external_signer_query_success) {
2278+
payload.insert(QStringLiteral("state"), QStringLiteral("error"));
2279+
payload.insert(
2280+
QStringLiteral("message"),
2281+
m_external_signer_query_error.isEmpty()
2282+
? QStringLiteral("Unable to detect external signer due to a mock error.")
2283+
: m_external_signer_query_error
2284+
);
2285+
payload.insert(QStringLiteral("count"), 0);
2286+
payload.insert(QStringLiteral("signers"), QVariantList{});
2287+
return payload;
2288+
}
2289+
2290+
payload.insert(QStringLiteral("signers"), m_external_signers);
2291+
payload.insert(QStringLiteral("count"), m_external_signers.size());
2292+
2293+
if (m_external_signers.isEmpty()) {
2294+
payload.insert(QStringLiteral("state"), QStringLiteral("missing"));
2295+
payload.insert(
2296+
QStringLiteral("message"),
2297+
QStringLiteral("No external signer detected. Connect your device and retry.")
2298+
);
2299+
} else if (m_external_signers.size() == 1) {
2300+
payload.insert(QStringLiteral("state"), QStringLiteral("available"));
2301+
payload.insert(
2302+
QStringLiteral("message"),
2303+
QStringLiteral("External signer detected: %1")
2304+
.arg(m_external_signers.constFirst().toString())
2305+
);
2306+
} else {
2307+
payload.insert(QStringLiteral("state"), QStringLiteral("multiple"));
2308+
payload.insert(
2309+
QStringLiteral("message"),
2310+
QStringLiteral("Multiple external signers detected. Connect only one signer and retry.")
2311+
);
2312+
}
2313+
2314+
return payload;
2315+
}
2316+
2317+
Q_INVOKABLE void setExternalSignerQueryResultForTest(const bool success,
2318+
const QVariantList& signers,
2319+
const QString& error)
2320+
{
2321+
m_external_signer_query_success = success;
2322+
m_external_signers = signers;
2323+
m_external_signer_query_error = error;
2324+
}
2325+
2326+
Q_INVOKABLE void resetExternalSignerQueryCallsForTest()
2327+
{
2328+
m_external_signer_query_calls = 0;
2329+
Q_EMIT externalSignerQueryCallsChanged();
2330+
}
2331+
22642332
Q_INVOKABLE QVariantMap broadcastSignedPsbt(const QString& psbt_base64)
22652333
{
22662334
++m_broadcast_signed_psbt_calls;
@@ -2300,9 +2368,11 @@ class MockNodeModel : public QObject
23002368
void faultedChanged();
23012369
void blockTipHeightChanged();
23022370
void broadcastSignedPsbtCallsChanged();
2371+
void externalSignerQueryCallsChanged();
23032372

23042373
private:
23052374
int m_broadcast_signed_psbt_calls{0};
2375+
int m_external_signer_query_calls{0};
23062376
};
23072377

23082378
class MockPeerTableModel : public QObject

test/qml/tst_psbtoperations.qml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ TestCase {
2828
verify(findChild(page, "psbtImportClipboardButton") !== null)
2929
verify(findChild(page, "psbtImportFileButton") !== null)
3030
verify(findChild(page, "psbtImportFileDialog") !== null)
31+
verify(findChild(page, "psbtExternalSignerPanel") !== null)
32+
verify(findChild(page, "psbtExternalSignerStatusText") !== null)
33+
verify(findChild(page, "psbtExternalSignerHintText") !== null)
34+
verify(findChild(page, "psbtExternalSignerNamesText") !== null)
35+
verify(findChild(page, "psbtRefreshSignersButton") !== null)
3136
verify(findChild(page, "psbtSignButton") !== null)
3237
verify(findChild(page, "psbtFinalizeButton") !== null)
3338
verify(findChild(page, "psbtBroadcastButton") !== null)
@@ -38,6 +43,38 @@ TestCase {
3843
verify(findChild(page, "psbtWorkflowStatusText") !== null)
3944
}
4045

46+
function test_external_signer_states_are_recoverable() {
47+
const page = createTemporaryObject(psbtOperationsComponent, this)
48+
verify(page !== null)
49+
50+
const refreshButton = findChild(page, "psbtRefreshSignersButton")
51+
const statusText = findChild(page, "psbtExternalSignerStatusText")
52+
const hintText = findChild(page, "psbtExternalSignerHintText")
53+
verify(refreshButton !== null)
54+
verify(statusText !== null)
55+
verify(hintText !== null)
56+
57+
nodeModel.resetExternalSignerQueryCallsForTest()
58+
59+
nodeModel.setExternalSignerQueryResultForTest(true, ["Coldcard"], "")
60+
refreshButton.clicked()
61+
compare(nodeModel.externalSignerQueryCalls, 1)
62+
verify(statusText.text.indexOf("Coldcard") !== -1)
63+
verify(hintText.text.indexOf("import the updated PSBT") !== -1)
64+
65+
nodeModel.setExternalSignerQueryResultForTest(true, [], "")
66+
refreshButton.clicked()
67+
compare(nodeModel.externalSignerQueryCalls, 2)
68+
verify(statusText.text.indexOf("No external signer") !== -1)
69+
verify(hintText.text.indexOf("Connect a compatible device") !== -1)
70+
71+
nodeModel.setExternalSignerQueryResultForTest(false, [], "Mock signer command failed")
72+
refreshButton.clicked()
73+
compare(nodeModel.externalSignerQueryCalls, 3)
74+
compare(statusText.text, "Mock signer command failed")
75+
compare(page.externalSignerState, "error")
76+
}
77+
4178
function test_psbt_operations_import_sign_broadcast_flow() {
4279
const page = createTemporaryObject(psbtOperationsComponent, this)
4380
verify(page !== null)

0 commit comments

Comments
 (0)