Skip to content

Commit aff31a4

Browse files
committed
Issue 17: add transaction RBF adapter with unit coverage
1 parent b920c0f commit aff31a4

File tree

5 files changed

+538
-0
lines changed

5 files changed

+538
-0
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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/transactionrbfadapter.h>
6+
7+
#include <util/translation.h>
8+
9+
#include <QObject>
10+
11+
namespace {
12+
constexpr unsigned int DEFAULT_RBF_CONFIRM_TARGET{6};
13+
constexpr qint64 ZERO_SATS{0};
14+
}
15+
16+
std::optional<Txid> TransactionRbfAdapter::ParseTxid(const QString& txid_text)
17+
{
18+
const QString normalized = txid_text.trimmed();
19+
if (normalized.isEmpty()) {
20+
return std::nullopt;
21+
}
22+
23+
return Txid::FromHex(normalized.toStdString());
24+
}
25+
26+
QString TransactionRbfAdapter::ErrorMessage(const std::vector<bilingual_str>& errors,
27+
const QString& fallback)
28+
{
29+
if (errors.empty()) {
30+
return fallback;
31+
}
32+
33+
const bilingual_str& first_error = errors.front();
34+
if (!first_error.translated.empty()) {
35+
return QString::fromStdString(first_error.translated);
36+
}
37+
38+
if (!first_error.original.empty()) {
39+
return QString::fromStdString(first_error.original);
40+
}
41+
42+
return fallback;
43+
}
44+
45+
bool TransactionRbfAdapter::CanBump(const TransactionRbfBackend& backend, const QString& txid_text)
46+
{
47+
const auto txid = ParseTxid(txid_text);
48+
return txid.has_value() && backend.transactionCanBeBumped(*txid);
49+
}
50+
51+
bool TransactionRbfAdapter::CanAbandon(const TransactionRbfBackend& backend, const QString& txid_text)
52+
{
53+
const auto txid = ParseTxid(txid_text);
54+
return txid.has_value() && backend.transactionCanBeAbandoned(*txid);
55+
}
56+
57+
QString TransactionRbfAdapter::BumpIneligibleReason()
58+
{
59+
return QObject::tr("Only unconfirmed replaceable transactions can be sped up.");
60+
}
61+
62+
QString TransactionRbfAdapter::AbandonIneligibleReason()
63+
{
64+
return QObject::tr("Only unconfirmed transactions without descendants can be canceled.");
65+
}
66+
67+
TransactionRbfPreview TransactionRbfAdapter::PrepareBump(TransactionRbfBackend& backend,
68+
const QString& txid_text,
69+
const unsigned int target_blocks,
70+
const bool custom_fee_enabled,
71+
const qint64 custom_fee_rate_sat_per_kvb)
72+
{
73+
TransactionRbfPreview preview;
74+
75+
const auto txid = ParseTxid(txid_text);
76+
if (!txid.has_value()) {
77+
preview.message = QObject::tr("Invalid transaction ID.");
78+
return preview;
79+
}
80+
81+
if (!backend.transactionCanBeBumped(*txid)) {
82+
preview.message = BumpIneligibleReason();
83+
return preview;
84+
}
85+
86+
const unsigned int normalized_target = target_blocks > 0 ? target_blocks : DEFAULT_RBF_CONFIRM_TARGET;
87+
std::optional<CAmount> custom_fee_sat_per_kvb{};
88+
89+
if (custom_fee_enabled) {
90+
if (custom_fee_rate_sat_per_kvb <= ZERO_SATS) {
91+
preview.message = QObject::tr("Custom fee must be greater than zero.");
92+
return preview;
93+
}
94+
custom_fee_sat_per_kvb = custom_fee_rate_sat_per_kvb;
95+
}
96+
97+
std::vector<bilingual_str> errors;
98+
if (!backend.createBumpTransaction(*txid,
99+
normalized_target,
100+
custom_fee_sat_per_kvb,
101+
errors,
102+
preview.old_fee,
103+
preview.new_fee,
104+
preview.replacement)) {
105+
preview.message = ErrorMessage(errors, QObject::tr("Could not prepare fee bump."));
106+
return preview;
107+
}
108+
109+
preview.success = true;
110+
preview.message = QObject::tr("Review the updated fee and confirm to broadcast.");
111+
return preview;
112+
}
113+
114+
TransactionRbfActionResult TransactionRbfAdapter::CommitBump(TransactionRbfBackend& backend,
115+
const QString& txid_text,
116+
CMutableTransaction&& replacement_tx)
117+
{
118+
TransactionRbfActionResult result;
119+
120+
const auto txid = ParseTxid(txid_text);
121+
if (!txid.has_value()) {
122+
result.message = QObject::tr("Invalid transaction ID.");
123+
return result;
124+
}
125+
126+
if (!backend.signBumpTransaction(replacement_tx)) {
127+
result.message = QObject::tr("Could not sign replacement transaction.");
128+
return result;
129+
}
130+
131+
std::vector<bilingual_str> errors;
132+
Txid replacement_txid;
133+
if (!backend.commitBumpTransaction(*txid,
134+
std::move(replacement_tx),
135+
errors,
136+
replacement_txid)) {
137+
result.message = ErrorMessage(errors, QObject::tr("Could not broadcast replacement transaction."));
138+
return result;
139+
}
140+
141+
result.success = true;
142+
result.replacement_txid = QString::fromStdString(replacement_txid.ToString());
143+
result.message = QObject::tr("Replacement transaction broadcast.");
144+
return result;
145+
}
146+
147+
TransactionRbfActionResult TransactionRbfAdapter::Abandon(TransactionRbfBackend& backend,
148+
const QString& txid_text)
149+
{
150+
TransactionRbfActionResult result;
151+
152+
const auto txid = ParseTxid(txid_text);
153+
if (!txid.has_value()) {
154+
result.message = QObject::tr("Invalid transaction ID.");
155+
return result;
156+
}
157+
158+
if (!backend.transactionCanBeAbandoned(*txid)) {
159+
result.message = AbandonIneligibleReason();
160+
return result;
161+
}
162+
163+
if (!backend.abandonTransaction(*txid)) {
164+
result.message = QObject::tr("Could not cancel transaction.");
165+
return result;
166+
}
167+
168+
result.success = true;
169+
result.message = QObject::tr("Transaction canceled.");
170+
return result;
171+
}

qml/models/transactionrbfadapter.h

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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_TRANSACTIONRBFADAPTER_H
6+
#define BITCOIN_QML_MODELS_TRANSACTIONRBFADAPTER_H
7+
8+
#include <consensus/amount.h>
9+
#include <primitives/transaction.h>
10+
#include <QString>
11+
12+
#include <optional>
13+
#include <vector>
14+
15+
struct bilingual_str;
16+
17+
class TransactionRbfBackend
18+
{
19+
public:
20+
virtual ~TransactionRbfBackend() = default;
21+
22+
virtual bool transactionCanBeBumped(const Txid& txid) const = 0;
23+
virtual bool createBumpTransaction(const Txid& txid,
24+
unsigned int target_blocks,
25+
std::optional<CAmount> custom_fee_sat_per_kvb,
26+
std::vector<bilingual_str>& errors,
27+
CAmount& old_fee,
28+
CAmount& new_fee,
29+
CMutableTransaction& mtx) = 0;
30+
virtual bool signBumpTransaction(CMutableTransaction& mtx) = 0;
31+
virtual bool commitBumpTransaction(const Txid& txid,
32+
CMutableTransaction&& mtx,
33+
std::vector<bilingual_str>& errors,
34+
Txid& bumped_txid) = 0;
35+
virtual bool transactionCanBeAbandoned(const Txid& txid) const = 0;
36+
virtual bool abandonTransaction(const Txid& txid) = 0;
37+
};
38+
39+
struct TransactionRbfPreview
40+
{
41+
bool success{false};
42+
QString message;
43+
CAmount old_fee{0};
44+
CAmount new_fee{0};
45+
CMutableTransaction replacement;
46+
};
47+
48+
struct TransactionRbfActionResult
49+
{
50+
bool success{false};
51+
QString message;
52+
QString replacement_txid;
53+
};
54+
55+
class TransactionRbfAdapter
56+
{
57+
public:
58+
static bool CanBump(const TransactionRbfBackend& backend, const QString& txid_text);
59+
static bool CanAbandon(const TransactionRbfBackend& backend, const QString& txid_text);
60+
61+
static QString BumpIneligibleReason();
62+
static QString AbandonIneligibleReason();
63+
64+
static TransactionRbfPreview PrepareBump(TransactionRbfBackend& backend,
65+
const QString& txid_text,
66+
unsigned int target_blocks,
67+
bool custom_fee_enabled,
68+
qint64 custom_fee_rate_sat_per_kvb);
69+
70+
static TransactionRbfActionResult CommitBump(TransactionRbfBackend& backend,
71+
const QString& txid_text,
72+
CMutableTransaction&& replacement_tx);
73+
74+
static TransactionRbfActionResult Abandon(TransactionRbfBackend& backend,
75+
const QString& txid_text);
76+
77+
private:
78+
static std::optional<Txid> ParseTxid(const QString& txid_text);
79+
static QString ErrorMessage(const std::vector<bilingual_str>& errors,
80+
const QString& fallback);
81+
};
82+
83+
#endif // BITCOIN_QML_MODELS_TRANSACTIONRBFADAPTER_H

test/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ add_executable(bitcoinqml_unit_tests
3838
test_testbridge.cpp
3939
test_transaction.cpp
4040
test_walletqmlmodeltransaction.cpp
41+
test_transactionrbfadapter.cpp
4142
${CMAKE_CURRENT_SOURCE_DIR}/../qml/bitcoinamount.cpp
4243
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/bitcoinaddress.cpp
4344
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/paymentrequest.cpp
@@ -71,6 +72,7 @@ add_executable(bitcoinqml_unit_tests
7172
${CMAKE_CURRENT_SOURCE_DIR}/../qml/test/testbridge.cpp
7273
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/transaction.cpp
7374
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/walletqmlmodeltransaction.cpp
75+
${CMAKE_CURRENT_SOURCE_DIR}/../qml/models/transactionrbfadapter.cpp
7476
)
7577

7678
target_compile_definitions(bitcoinqml_unit_tests

0 commit comments

Comments
 (0)