Files
nerbus-wallet/wallet_app.cpp
T

434 lines
26 KiB
C++

#include "wallet_store.h"
#include <QtWidgets>
#include <QtNetwork>
#include <sodium.h>
#include <functional>
namespace {
constexpr qint64 Coin = 100000000;
constexpr qint64 MaxMoney = 209000000LL * Coin;
QString money(qint64 value) {
return QString::number(value / Coin) + "." + QString::number(qAbs(value % Coin)).rightJustified(8, '0');
}
qint64 parseMoney(const QString &text) {
static const QRegularExpression pattern("^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,8})?$");
if (!pattern.match(text).hasMatch()) walletError("Enter an amount with up to eight decimal places.");
const auto parts = text.split('.');
qint64 value = parts[0].toLongLong() * Coin;
if (parts.size() == 2) value += parts[1].leftJustified(8, '0').toLongLong();
if (value > MaxMoney) walletError("Amount is outside the NRB money range.");
return value;
}
qint64 atomicAmount(const QJsonValue &v) {
bool ok = false;
qint64 value = v.toString().toLongLong(&ok);
if (!ok || value < 0 || value > MaxMoney) walletError("The network returned an invalid amount.");
return value;
}
QByteArray askPassword(QWidget *parent, const QString &title, const QString &text) {
QInputDialog dialog(parent);
dialog.setWindowTitle(title); dialog.setLabelText(text);
dialog.setTextEchoMode(QLineEdit::Password); dialog.resize(460, 160);
if (dialog.exec() != QDialog::Accepted) return {};
QString password = dialog.textValue();
QByteArray bytes = password.toUtf8();
password.fill(QChar(0)); dialog.setTextValue({});
return bytes;
}
QLabel *textLabel(const QString &text) {
auto *label = new QLabel(text); label->setWordWrap(true); return label;
}
class WalletWindow : public QMainWindow {
WalletStore store;
QNetworkAccessManager manager;
QTimer refreshTimer, idleTimer;
QLabel *connection, *balance, *pending, *receiveStatus, *walletStatus, *sendStatus;
QLabel *receiveAddress;
QLineEdit *labelEdit, *recipient, *amountEdit, *feeEdit;
QListWidget *addresses, *transactions;
QPushButton *newAddress, *unlockButton, *sendButton;
QTabWidget *tabs;
bool busy = false;
bool refreshing = false;
bool online = false;
int sessionGeneration = 0;
QString lastTxid;
void report(const std::exception &e) {
QMessageBox::warning(this, "Nerbus Wallet", QString::fromUtf8(e.what()));
}
void guarded(const std::function<void()> &fn) {
try { fn(); } catch (const std::exception &e) { report(e); }
}
QJsonArray publicAddresses() const {
QJsonArray result;
for (auto item : store.entries()) result.append(item.toObject()["address"]);
return result;
}
void api(QJsonObject request, const std::function<void(QJsonObject, QString)> &done) {
QNetworkRequest req(QUrl("https://explorer.nerbus.com/wallet/v1"));
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
req.setRawHeader("User-Agent", "NerbusWallet/0.2.1");
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy);
req.setTransferTimeout(30000);
auto *reply = manager.post(req, QJsonDocument(request).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::readyRead, this, [reply] {
if (reply->bytesAvailable() > 16 * 1024 * 1024) reply->abort();
});
connect(reply, &QNetworkReply::finished, this, [this, reply, done] {
QByteArray bytes = reply->readAll();
auto doc = QJsonDocument::fromJson(bytes);
auto result = doc.object();
QString error;
if (bytes.size() > 16 * 1024 * 1024 || !doc.isObject())
error = "Network service is unavailable.";
else if (result.contains("error")) error = result["error"].toString("Network request failed.").left(300);
else if (reply->error() != QNetworkReply::NoError) error = "Cannot connect to the NRB network service.";
else if (result["genesis"].toString() != NrbGenesis) error = "Network identity does not match NRB.";
reply->deleteLater();
try { done(result, error); } catch (const std::exception &e) { busy = false; updateButtons(); report(e); }
});
}
void updateButtons() {
newAddress->setEnabled(!busy);
sendButton->setEnabled(!busy && online && store.unlocked());
unlockButton->setText(store.unlocked() ? (store.passwordProtected() ? "Lock wallet" : "Wallet open")
: store.exists() ? "Open wallet" : "Create wallet");
unlockButton->setEnabled(!busy && (!store.unlocked() || store.passwordProtected()));
walletStatus->setText(store.unlocked() ? (store.passwordProtected() ? "Wallet unlocked. Password protection is on."
: "Wallet open. Password protection is off.")
: store.exists() ? "Open your wallet to view your addresses." : "Create a wallet or restore a backup.");
}
void walletOpened() {
++sessionGeneration;
if (store.passwordProtected()) idleTimer.start(); else idleTimer.stop();
populateAddresses(); updateButtons(); refresh();
}
bool ensureUnlocked() {
if (busy) return false;
if (store.unlocked()) { if (store.passwordProtected()) idleTimer.start(); return true; }
if (!store.exists()) {
store.create({});
} else if (!WalletStore::fileRequiresPassword(store.path())) {
store.unlock({});
} else {
QByteArray password = askPassword(this, "Unlock wallet", "Wallet password:");
if (password.isEmpty()) return false;
store.unlock(std::move(password));
}
walletOpened();
return true;
}
void setPassword() {
if (!ensureUnlocked()) return;
QByteArray password = askPassword(this, "Set wallet password", "New wallet password (optional protection):");
if (password.isEmpty()) return;
QByteArray confirmation = askPassword(this, "Confirm password", "Enter the same password again:");
const bool same = password == confirmation;
wipe(confirmation);
if (!same) { wipe(password); walletError("Passwords do not match."); }
store.changePassword(std::move(password));
idleTimer.start(); updateButtons();
walletStatus->setText("Password protection is on. Existing backup files retain their previous protection.");
}
void removePassword() {
if (!ensureUnlocked()) return;
if (store.passwordProtected()) store.changePassword({});
idleTimer.stop(); updateButtons();
}
void lockWallet() {
if (busy || !store.passwordProtected()) return;
++sessionGeneration; idleTimer.stop(); store.lock();
addresses->clear(); transactions->clear(); receiveAddress->setText("Unlock your wallet to view your addresses.");
receiveStatus->clear(); balance->setText("— NRB"); pending->clear();
updateButtons();
}
void populateAddresses(const QString &selected = {}) {
addresses->clear();
if (!store.unlocked()) return;
for (auto item : store.entries()) {
const auto obj = item.toObject();
QString address = obj["address"].toString();
QString label = obj["label"].toString();
auto *row = new QListWidgetItem(label.isEmpty() ? address : label + "\n" + address, addresses);
row->setData(Qt::UserRole, address);
if (address == selected) addresses->setCurrentItem(row);
}
if (!addresses->currentItem() && addresses->count()) addresses->setCurrentRow(addresses->count() - 1);
}
void makeAddress() {
try {
if (!ensureUnlocked()) return;
busy = true; updateButtons();
const auto pair = offlineCore({{"action", "newKey"}});
QString address = store.addKey(pair, labelEdit->text().trimmed());
populateAddresses(address); labelEdit->clear(); receiveStatus->clear();
busy = false; updateButtons();
} catch (const std::exception &e) {
busy = false; updateButtons(); receiveStatus->setText(QString::fromUtf8(e.what()));
}
}
void refresh() {
if (refreshing || busy) return;
refreshing = true;
const int generation = sessionGeneration;
QJsonObject request{{"action", "status"}};
if (store.unlocked() && !store.entries().empty()) {
request["action"] = "account"; request["addresses"] = publicAddresses();
}
api(request, [this, generation](QJsonObject result, QString error) {
refreshing = false;
if (generation != sessionGeneration) return;
online = error.isEmpty() && result["ready"].toBool();
connection->setText(error.isEmpty() ? (online ? "Connected · Block " + QString::number(result["height"].toInt())
: "Waiting for the NRB network")
: "Network unavailable · Addresses remain available");
if (!error.isEmpty()) connection->setToolTip(error);
else connection->setToolTip("Network information is provided by explorer.nerbus.com.");
if (store.unlocked() && result.contains("balance") && error.isEmpty()) {
balance->setText(money(atomicAmount(result["balance"])) + " NRB");
pending->setText("Confirmed balance · some outputs may still be immature");
transactions->clear();
for (const auto &entry : result["transactions"].toArray()) {
QString txid = entry.toString();
if (QRegularExpression("^[a-f0-9]{64}$").match(txid).hasMatch())
transactions->addItem(txid);
}
} else if (!error.isEmpty()) {
balance->setText("— NRB"); pending->setText("Balance will update when the network is available.");
} else if (store.unlocked() && store.entries().empty()) {
balance->setText("0.00000000 NRB"); pending->clear();
}
updateButtons();
});
}
void backup() {
if (!store.exists()) { if (!ensureUnlocked()) return; }
const QString path = QFileDialog::getSaveFileName(this, "Back up wallet",
QDir::homePath() + "/Nerbus-backup-" + QDate::currentDate().toString("yyyy-MM-dd") + ".nrb", "Nerbus wallet (*.nrb)");
if (path.isEmpty()) return;
store.backup(path);
walletStatus->setText("Backup saved. Back up again after creating or importing new addresses.");
}
void restore() {
if (store.exists()) walletError("An existing wallet will not be replaced. Start with --wallet-dir=<new folder> to restore a separate wallet.");
const QString source = QFileDialog::getOpenFileName(this, "Restore wallet", {}, "Nerbus wallet (*.nrb)");
if (source.isEmpty()) return;
QByteArray password;
if (WalletStore::fileRequiresPassword(source)) {
password = askPassword(this, "Restore wallet", "Password for this encrypted backup:");
if (password.isEmpty()) return;
}
store.restore(source, std::move(password)); walletOpened();
}
void importKey() {
if (!ensureUnlocked()) return;
QByteArray value = askPassword(this, "Import private key", "NRB private key (WIF):");
if (value.isEmpty()) return;
QJsonObject request{{"action", "importKey"}, {"privateKey", QString::fromUtf8(value)}};
wipe(value);
auto pair = offlineCore(request); request = {};
QString address = store.addKey(pair, "Imported");
populateAddresses(address); tabs->setCurrentIndex(1); refresh();
}
void exportKey() {
if (!ensureUnlocked()) return;
auto *current = addresses->currentItem();
if (!current) walletError("Select a receiving address first.");
const QString address = current->data(Qt::UserRole).toString();
if (QMessageBox::warning(this, "Export private key",
"Anyone with this private key can spend the coins at this address. Show it?",
QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel) != QMessageBox::Yes) return;
for (const auto &entry : store.entries()) if (entry.toObject()["address"] == address) {
QDialog dialog(this); dialog.setWindowTitle("NRB private key");
QVBoxLayout layout(&dialog); QLabel info("Private key for " + address);
QLineEdit edit(entry.toObject()["privateKey"].toString()); edit.setReadOnly(true);
QPushButton close("Close"); layout.addWidget(&info); layout.addWidget(&edit); layout.addWidget(&close);
connect(&close, &QPushButton::clicked, &dialog, &QDialog::accept);
dialog.resize(620, 150); dialog.exec(); edit.clear(); return;
}
}
void sendPayment() {
if (!ensureUnlocked()) return;
const QString to = recipient->text().trimmed();
if (!QRegularExpression("^[1-9A-HJ-NP-Za-km-z]{26,36}$").match(to).hasMatch())
walletError("Enter a transparent NRB receiving address.");
const qint64 amount = parseMoney(amountEdit->text().trimmed());
const qint64 fee = parseMoney(feeEdit->text().trimmed());
if (amount <= 0 || fee < 10000 || fee > Coin) walletError("Amount must be positive. The fee must be between 0.00010000 and 1 NRB.");
const QJsonArray owned = publicAddresses();
if (owned.empty()) walletError("Create a receiving address first.");
busy = true; updateButtons(); sendStatus->setText("Preparing payment…");
api({{"action", "inputs"}, {"addresses", owned}, {"target", QString::number(amount + fee)}},
[this, to, amount, fee](QJsonObject result, QString error) {
if (!error.isEmpty()) { busy = false; updateButtons(); sendStatus->setText(error); return; }
if (!result["ready"].toBool()) walletError("The network is not ready for payments.");
const auto inputs = result["inputs"].toArray();
qint64 total = 0;
QJsonArray signingInputs;
for (const auto &entry : inputs) {
auto in = entry.toObject();
qint64 value = atomicAmount(in["satoshis"]);
if (total > MaxMoney - value) walletError("Invalid total input value.");
total += value;
in["amount"] = money(value); signingInputs.append(in);
}
qint64 effectiveFee = fee;
if (total < amount + fee) walletError("Insufficient spendable funds.");
if (total - amount - fee > 0 && total - amount - fee < 1000) effectiveFee = total - amount;
const QString summary = "Send " + money(amount) + " NRB\nto " + to +
"\n\nNetwork fee: " + money(effectiveFee) + " NRB\nTotal: " + money(amount + effectiveFee) + " NRB";
if (QMessageBox::question(this, "Confirm payment", summary, QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel) != QMessageBox::Yes) { busy = false; updateButtons(); sendStatus->clear(); return; }
QJsonArray keys;
const auto entries = store.entries();
for (const auto &entry : entries) keys.append(entry.toObject()["privateKey"]);
QJsonObject signedTx = offlineCore({{"action", "payment"}, {"inputs", signingInputs}, {"privateKeys", keys},
{"height", result["height"].toInt() + 1}, {"to", to}, {"amount", money(amount)}, {"fee", money(effectiveFee)},
{"change", entries.first().toObject()["address"]}});
keys = {};
lastTxid = signedTx["txid"].toString();
// Retain a public signed transaction so a timeout cannot hide what was submitted.
const QString receiptDir = QFileInfo(store.path()).absolutePath() + "/payments";
if (!QDir().mkpath(receiptDir)) walletError("Cannot save the payment receipt.");
QSaveFile receipt(receiptDir + "/" + lastTxid + ".json");
QByteArray receiptData = QJsonDocument(signedTx).toJson();
if (!receipt.open(QIODevice::WriteOnly) || receipt.write(receiptData) != receiptData.size() || !receipt.commit())
walletError("Cannot save the payment receipt.");
sendStatus->setText("Submitting transaction " + lastTxid + "…");
api({{"action", "broadcast"}, {"hex", signedTx["hex"]}}, [this](QJsonObject reply, QString error) {
busy = false; updateButtons();
if (!error.isEmpty()) {
sendStatus->setText("Submission was not confirmed. Check transaction " + lastTxid +
" in the explorer before sending again. " + error);
} else if (reply["txid"].toString() != lastTxid) {
sendStatus->setText("Unexpected transaction response. Check " + lastTxid + " in the explorer before sending again.");
} else {
sendStatus->setText("Transaction submitted: " + lastTxid); amountEdit->clear(); refresh();
}
});
});
}
protected:
bool eventFilter(QObject *obj, QEvent *event) override {
if (store.passwordProtected() && (event->type() == QEvent::KeyPress || event->type() == QEvent::MouseButtonPress)) idleTimer.start();
return QMainWindow::eventFilter(obj, event);
}
public:
explicit WalletWindow(QString path) : store(std::move(path)) {
setWindowTitle("Nerbus Wallet"); resize(1000, 720);
setWindowIcon(QIcon(QCoreApplication::applicationDirPath() + "/nerbus.svg"));
auto *root = new QWidget; auto *layout = new QVBoxLayout(root); layout->setContentsMargins(28, 20, 28, 20);
auto *masthead = new QHBoxLayout;
auto *logo = new QLabel; logo->setPixmap(QIcon(QCoreApplication::applicationDirPath() + "/nerbus.svg").pixmap(64, 64));
auto *name = new QLabel("NERBUS"); name->setObjectName("masthead");
masthead->addWidget(logo); masthead->addWidget(name); masthead->addStretch();
auto *unit = new QLabel("THE NRB WALLET\nYour keys. Your coins."); unit->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
masthead->addWidget(unit); layout->addLayout(masthead);
connection = textLabel("Connecting to the NRB network…"); layout->addWidget(connection);
tabs = new QTabWidget; layout->addWidget(tabs, 1);
auto *overview = new QWidget; auto *ov = new QVBoxLayout(overview);
ov->addWidget(textLabel("BALANCE")); balance = new QLabel("— NRB"); balance->setObjectName("balance"); ov->addWidget(balance);
pending = textLabel(""); ov->addWidget(pending); ov->addSpacing(20);
ov->addWidget(textLabel("Receive NRB using an address from your wallet. Your private keys stay on this computer."));
auto *receiveShortcut = new QPushButton("Receive NRB"); ov->addWidget(receiveShortcut, 0, Qt::AlignLeft); ov->addStretch();
tabs->addTab(overview, "Overview");
auto *receive = new QWidget; auto *rv = new QVBoxLayout(receive);
labelEdit = new QLineEdit; labelEdit->setPlaceholderText("Address label (optional)");
newAddress = new QPushButton("New receiving address"); auto *newRow = new QHBoxLayout;
newRow->addWidget(labelEdit, 1); newRow->addWidget(newAddress); rv->addLayout(newRow);
receiveAddress = textLabel("Click New receiving address to receive NRB.");
receiveAddress->setTextInteractionFlags(Qt::TextSelectableByMouse); receiveAddress->setObjectName("address"); rv->addWidget(receiveAddress);
auto *copy = new QPushButton("Copy address"); rv->addWidget(copy, 0, Qt::AlignLeft);
receiveStatus = textLabel(""); receiveStatus->setStyleSheet("color:#8b241d"); rv->addWidget(receiveStatus);
addresses = new QListWidget; rv->addWidget(addresses, 1); tabs->addTab(receive, "Receive");
connect(addresses, &QListWidget::currentItemChanged, this, [this](QListWidgetItem *current) {
if (current) receiveAddress->setText(current->data(Qt::UserRole).toString());
});
connect(newAddress, &QPushButton::clicked, this, [this] { makeAddress(); });
connect(receiveShortcut, &QPushButton::clicked, this, [this] { tabs->setCurrentIndex(1); });
connect(copy, &QPushButton::clicked, this, [this] {
if (auto *item = addresses->currentItem()) QApplication::clipboard()->setText(item->data(Qt::UserRole).toString());
});
auto *send = new QWidget; auto *sv = new QVBoxLayout(send); auto *form = new QFormLayout;
recipient = new QLineEdit; recipient->setPlaceholderText("Recipient NRB address");
amountEdit = new QLineEdit; amountEdit->setPlaceholderText("0.00000000");
feeEdit = new QLineEdit("0.00010000");
form->addRow("To", recipient); form->addRow("Amount (NRB)", amountEdit); form->addRow("Network fee (NRB)", feeEdit);
sv->addLayout(form); sendButton = new QPushButton("Review payment"); sv->addWidget(sendButton, 0, Qt::AlignLeft);
sendStatus = textLabel(""); sendStatus->setTextInteractionFlags(Qt::TextSelectableByMouse); sv->addWidget(sendStatus);
sv->addStretch(); tabs->addTab(send, "Send");
connect(sendButton, &QPushButton::clicked, this, [this] { guarded([this] { sendPayment(); }); });
auto *history = new QWidget; auto *hv = new QVBoxLayout(history);
hv->addWidget(textLabel("Recent transactions · double-click to open in the explorer"));
transactions = new QListWidget; hv->addWidget(transactions, 1); tabs->addTab(history, "Transactions");
connect(transactions, &QListWidget::itemDoubleClicked, this, [](QListWidgetItem *item) {
if (QRegularExpression("^[a-f0-9]{64}$").match(item->text()).hasMatch())
QDesktopServices::openUrl(QUrl("https://explorer.nerbus.com/tx/" + item->text()));
});
auto *settings = new QWidget; auto *wv = new QVBoxLayout(settings);
walletStatus = textLabel(""); wv->addWidget(walletStatus); unlockButton = new QPushButton;
wv->addWidget(unlockButton, 0, Qt::AlignLeft);
auto addAction = [this, wv](QString title, std::function<void()> fn) {
auto *button = new QPushButton(title); wv->addWidget(button, 0, Qt::AlignLeft);
connect(button, &QPushButton::clicked, this, [this, fn] { if (!busy) guarded(fn); });
};
addAction("Back up wallet", [this] { backup(); });
addAction("Restore backup", [this] { restore(); });
addAction("Set password (optional)", [this] { setPassword(); });
addAction("Remove password", [this] { removePassword(); });
addAction("Import private key", [this] { importKey(); });
addAction("Export selected address private key", [this] { exportKey(); });
wv->addSpacing(12);
auto *location = textLabel("Wallet file: " + store.path()); location->setTextInteractionFlags(Qt::TextSelectableByMouse); wv->addWidget(location);
wv->addWidget(textLabel("Save a new backup after adding addresses. Password protection is optional. Without it, wallet files and backups contain unencrypted private keys.\nNetwork data is provided by explorer.nerbus.com. This wallet supports transparent NRB addresses."));
wv->addStretch(); tabs->addTab(settings, "Wallet & backup");
connect(unlockButton, &QPushButton::clicked, this, [this] { guarded([this] { if (store.unlocked()) lockWallet(); else ensureUnlocked(); }); });
auto *footer = new QLabel("NERBUS / NRB 0.2.1"); layout->addWidget(footer);
setCentralWidget(root);
setStyleSheet("QMainWindow,QWidget{background:#f2ecdf;color:#211f19;font-family:'Georgia';font-size:15px;}"
"QLabel#masthead{font-size:48px;font-weight:bold;letter-spacing:5px;} QLabel#balance{font-size:38px;font-weight:bold;}"
"QLabel#address{font-family:'Consolas';font-size:21px;padding:18px 0;}"
"QTabWidget::pane{border:1px solid #29261e;padding:18px;} QTabBar::tab{padding:12px 18px;border:1px solid #29261e;}"
"QTabBar::tab:selected{background:#29261e;color:#f2ecdf;} QPushButton{background:#29261e;color:#fff8e8;padding:11px 16px;border:0;}"
"QPushButton:hover{background:#554b36;} QPushButton:disabled{background:#b8b0a0;color:#716956;}"
"QLineEdit{background:#fffaf0;border:1px solid #8c806c;padding:10px;selection-background-color:#554b36;}"
"QListWidget{background:#fffaf0;border:1px solid #8c806c;} QListWidget::item{padding:9px;border-bottom:1px solid #ded4c1;}"
"QListWidget::item:selected{background:#ded4c1;color:#211f19;}");
refreshTimer.setInterval(30000); connect(&refreshTimer, &QTimer::timeout, this, [this] { refresh(); }); refreshTimer.start();
idleTimer.setInterval(10 * 60 * 1000); connect(&idleTimer, &QTimer::timeout, this, [this] { lockWallet(); });
qApp->installEventFilter(this); updateButtons();
QTimer::singleShot(0, this, [this] {
guarded([this] {
if (store.exists() && !WalletStore::fileRequiresPassword(store.path())) {
store.unlock({}); walletOpened();
} else refresh();
});
});
}
};
}
int main(int argc, char **argv) {
QApplication app(argc, argv);
QCoreApplication::setOrganizationName("Nerbus"); QCoreApplication::setApplicationName("Nerbus Wallet");
QCoreApplication::setApplicationVersion("0.2.1");
if (sodium_init() < 0) { QMessageBox::critical(nullptr, "Nerbus Wallet", "Secure cryptography is unavailable."); return 1; }
QString directory = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + "/Nerbus/Wallet";
for (const QString &arg : app.arguments().mid(1)) {
if (arg.startsWith("--wallet-dir=")) directory = QFileInfo(arg.mid(13)).absoluteFilePath();
else { QMessageBox::critical(nullptr, "Nerbus Wallet", "Unsupported wallet argument."); return 1; }
}
if (!QDir().mkpath(directory)) { QMessageBox::critical(nullptr, "Nerbus Wallet", "Cannot create the wallet folder."); return 1; }
QLockFile lock(directory + "/wallet.lock"); lock.setStaleLockTime(0);
if (!lock.tryLock(0)) { QMessageBox::information(nullptr, "Nerbus Wallet", "This wallet is already open."); return 1; }
WalletWindow window(directory + "/wallet.nrb"); window.show(); return app.exec();
}