Skip to content

Commit de11d09

Browse files
committed
Issue 18: add PSBT decode/export adapter with unit tests
1 parent 1c257b0 commit de11d09

File tree

5 files changed

+244
-0
lines changed

5 files changed

+244
-0
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 The Bitcoin Core developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
#include <qml/models/psbtoperationsadapter.h>
6+
7+
#include <psbt.h>
8+
#include <streams.h>
9+
#include <util/strencodings.h>
10+
11+
#include <QObject>
12+
13+
#include <string>
14+
#include <vector>
15+
16+
namespace {
17+
PsbtDecodeResult DecodeRawPayload(const std::vector<unsigned char>& payload)
18+
{
19+
PsbtDecodeResult result;
20+
21+
std::string decode_error;
22+
PartiallySignedTransaction psbt;
23+
if (!DecodeRawPSBT(psbt, MakeByteSpan(payload), decode_error)) {
24+
result.success = false;
25+
result.message = QObject::tr("Unable to decode PSBT") + QStringLiteral("\n") + QString::fromStdString(decode_error);
26+
return result;
27+
}
28+
29+
result.success = true;
30+
result.psbt = std::move(psbt);
31+
return result;
32+
}
33+
} // namespace
34+
35+
PsbtDecodeResult PsbtOperationsAdapter::DecodeClipboardBase64(const QString& base64_text)
36+
{
37+
const QString trimmed = base64_text.trimmed();
38+
if (trimmed.isEmpty()) {
39+
return {
40+
false,
41+
QObject::tr("Clipboard does not contain PSBT data."),
42+
std::nullopt,
43+
};
44+
}
45+
46+
const auto decoded = DecodeBase64(trimmed.toStdString());
47+
if (!decoded.has_value()) {
48+
return {
49+
false,
50+
QObject::tr("Unable to decode PSBT from clipboard (invalid base64)"),
51+
std::nullopt,
52+
};
53+
}
54+
55+
return DecodeRawPayload(*decoded);
56+
}
57+
58+
PsbtDecodeResult PsbtOperationsAdapter::DecodeFilePayload(const QByteArray& payload, qint64 file_size_bytes)
59+
{
60+
const qint64 effective_size = file_size_bytes < 0 ? payload.size() : file_size_bytes;
61+
if (effective_size <= 0) {
62+
return {
63+
false,
64+
QObject::tr("PSBT file is empty."),
65+
std::nullopt,
66+
};
67+
}
68+
69+
if (effective_size > MAX_PSBT_FILE_SIZE_BYTES) {
70+
return {
71+
false,
72+
QObject::tr("PSBT file must be smaller than 100 MiB"),
73+
std::nullopt,
74+
};
75+
}
76+
77+
std::vector<unsigned char> decode_payload(payload.cbegin(), payload.cend());
78+
79+
// Some PSBT files may contain base64 text rather than binary PSBT bytes.
80+
std::string maybe_base64(payload.constData(), payload.size());
81+
const std::string whitespace_chars{" \t\n\r\f\v"};
82+
const std::string::size_type last_non_whitespace = maybe_base64.find_last_not_of(whitespace_chars);
83+
if (last_non_whitespace == std::string::npos) {
84+
maybe_base64.clear();
85+
} else {
86+
maybe_base64.erase(last_non_whitespace + 1);
87+
}
88+
89+
const auto decoded = DecodeBase64(maybe_base64);
90+
if (decoded.has_value()) {
91+
decode_payload = *decoded;
92+
}
93+
94+
return DecodeRawPayload(decode_payload);
95+
}
96+
97+
QString PsbtOperationsAdapter::EncodeBase64(const PartiallySignedTransaction& psbt)
98+
{
99+
DataStream stream{};
100+
stream << psbt;
101+
return QString::fromStdString(::EncodeBase64(stream.str()));
102+
}
103+
104+
QByteArray PsbtOperationsAdapter::EncodeRaw(const PartiallySignedTransaction& psbt)
105+
{
106+
DataStream stream{};
107+
stream << psbt;
108+
109+
const std::string raw = stream.str();
110+
return QByteArray(raw.data(), static_cast<int>(raw.size()));
111+
}

qml/models/psbtoperationsadapter.h

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 The Bitcoin Core developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
#ifndef BITCOIN_QML_MODELS_PSBTOPERATIONSADAPTER_H
6+
#define BITCOIN_QML_MODELS_PSBTOPERATIONSADAPTER_H
7+
8+
#include <psbt.h>
9+
10+
#include <optional>
11+
12+
#include <QByteArray>
13+
#include <QString>
14+
#include <QtGlobal>
15+
16+
struct PsbtDecodeResult {
17+
bool success{false};
18+
QString message;
19+
std::optional<PartiallySignedTransaction> psbt;
20+
};
21+
22+
class PsbtOperationsAdapter
23+
{
24+
public:
25+
static constexpr qint64 MAX_PSBT_FILE_SIZE_BYTES{100 * 1024 * 1024};
26+
27+
static PsbtDecodeResult DecodeClipboardBase64(const QString& base64_text);
28+
static PsbtDecodeResult DecodeFilePayload(const QByteArray& payload, qint64 file_size_bytes = -1);
29+
30+
static QString EncodeBase64(const PartiallySignedTransaction& psbt);
31+
static QByteArray EncodeRaw(const PartiallySignedTransaction& psbt);
32+
};
33+
34+
#endif // BITCOIN_QML_MODELS_PSBTOPERATIONSADAPTER_H

test/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ add_executable(bitcoinqml_unit_tests
3434
test_walletlifecycleadapter.cpp
3535
test_walletrestoreadapter.cpp
3636
test_walletmigrationadapter.cpp
37+
test_psbtoperationsadapter.cpp
3738
test_initexecutor.cpp
3839
test_testbridge.cpp
3940
test_transaction.cpp
@@ -68,6 +69,7 @@ add_executable(bitcoinqml_unit_tests
6869
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/walletlifecycleadapter.cpp
6970
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/walletrestoreadapter.cpp
7071
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/walletmigrationadapter.cpp
72+
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/psbtoperationsadapter.cpp
7173
${CMAKE_CURRENT_SOURCE_DIR}/../bitcoin/src/rpc/client.cpp
7274
${CMAKE_CURRENT_SOURCE_DIR}/../qml/test/testbridge.cpp
7375
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/transaction.cpp
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 The Bitcoin Core developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
#include <qml/models/psbtoperationsadapter.h>
6+
7+
#include <primitives/transaction.h>
8+
#include <script/script.h>
9+
10+
#include <QtTest/QtTest>
11+
12+
namespace {
13+
PartiallySignedTransaction BuildSamplePsbt()
14+
{
15+
CMutableTransaction tx;
16+
tx.version = 2;
17+
tx.vin.emplace_back(COutPoint{Txid{}, 0}, CScript{}, 0xFFFFFFFFU);
18+
tx.vout.emplace_back(CAmount{25000}, CScript{});
19+
return PartiallySignedTransaction{tx};
20+
}
21+
} // namespace
22+
23+
class PsbtOperationsAdapterTests : public QObject
24+
{
25+
Q_OBJECT
26+
27+
private Q_SLOTS:
28+
void decode_clipboard_requires_valid_base64()
29+
{
30+
const PsbtDecodeResult result = PsbtOperationsAdapter::DecodeClipboardBase64(QStringLiteral("not_base64_psbt"));
31+
QVERIFY(!result.success);
32+
QCOMPARE(result.message, QStringLiteral("Unable to decode PSBT from clipboard (invalid base64)"));
33+
QVERIFY(!result.psbt.has_value());
34+
}
35+
36+
void decode_clipboard_roundtrip_success()
37+
{
38+
const PartiallySignedTransaction source = BuildSamplePsbt();
39+
const QString encoded = PsbtOperationsAdapter::EncodeBase64(source);
40+
41+
const PsbtDecodeResult result = PsbtOperationsAdapter::DecodeClipboardBase64(encoded);
42+
QVERIFY(result.success);
43+
QVERIFY(result.psbt.has_value());
44+
QCOMPARE(PsbtOperationsAdapter::EncodeBase64(*result.psbt), encoded);
45+
}
46+
47+
void decode_file_binary_roundtrip_success()
48+
{
49+
const PartiallySignedTransaction source = BuildSamplePsbt();
50+
const QByteArray raw = PsbtOperationsAdapter::EncodeRaw(source);
51+
52+
const PsbtDecodeResult result = PsbtOperationsAdapter::DecodeFilePayload(raw);
53+
QVERIFY(result.success);
54+
QVERIFY(result.psbt.has_value());
55+
QCOMPARE(PsbtOperationsAdapter::EncodeRaw(*result.psbt), raw);
56+
}
57+
58+
void decode_file_base64_payload_success()
59+
{
60+
const PartiallySignedTransaction source = BuildSamplePsbt();
61+
QByteArray payload = PsbtOperationsAdapter::EncodeBase64(source).toUtf8();
62+
payload.append("\n\t");
63+
64+
const PsbtDecodeResult result = PsbtOperationsAdapter::DecodeFilePayload(payload);
65+
QVERIFY(result.success);
66+
QVERIFY(result.psbt.has_value());
67+
QCOMPARE(PsbtOperationsAdapter::EncodeBase64(*result.psbt), PsbtOperationsAdapter::EncodeBase64(source));
68+
}
69+
70+
void decode_file_rejects_empty_payload()
71+
{
72+
const PsbtDecodeResult result = PsbtOperationsAdapter::DecodeFilePayload(QByteArray{});
73+
QVERIFY(!result.success);
74+
QCOMPARE(result.message, QStringLiteral("PSBT file is empty."));
75+
}
76+
77+
void decode_file_rejects_large_payload()
78+
{
79+
const PsbtDecodeResult result = PsbtOperationsAdapter::DecodeFilePayload(QByteArrayLiteral("abc"),
80+
PsbtOperationsAdapter::MAX_PSBT_FILE_SIZE_BYTES + 1);
81+
QVERIFY(!result.success);
82+
QCOMPARE(result.message, QStringLiteral("PSBT file must be smaller than 100 MiB"));
83+
}
84+
};
85+
86+
int RunPsbtOperationsAdapterTests(int argc, char* argv[])
87+
{
88+
PsbtOperationsAdapterTests tests;
89+
return QTest::qExec(&tests, argc, argv);
90+
}
91+
92+
#ifndef BITCOINQML_NO_TEST_MAIN
93+
QTEST_MAIN(PsbtOperationsAdapterTests)
94+
#endif
95+
#include "test_psbtoperationsadapter.moc"

test/test_unit_tests_main.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ int RunWalletLoadErrorTests(int argc, char* argv[]);
3434
int RunWalletLifecycleAdapterTests(int argc, char* argv[]);
3535
int RunWalletRestoreAdapterTests(int argc, char* argv[]);
3636
int RunWalletMigrationAdapterTests(int argc, char* argv[]);
37+
int RunPsbtOperationsAdapterTests(int argc, char* argv[]);
3738
int RunQmlInitExecutorTests(int argc, char* argv[]);
3839
int RunTestBridgeTests(int argc, char* argv[]);
3940
int RunTransactionTests(int argc, char* argv[]);
@@ -75,6 +76,7 @@ int main(int argc, char* argv[])
7576
status |= RunWalletLifecycleAdapterTests(argc, argv);
7677
status |= RunWalletRestoreAdapterTests(argc, argv);
7778
status |= RunWalletMigrationAdapterTests(argc, argv);
79+
status |= RunPsbtOperationsAdapterTests(argc, argv);
7880
status |= RunQmlInitExecutorTests(argc, argv);
7981
status |= RunTestBridgeTests(argc, argv);
8082
status |= RunTransactionTests(argc, argv);

0 commit comments

Comments
 (0)