#include "wallet_store.h" #include #include #include #include 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 &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 &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= 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 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(); }