Publish Nerbus source snapshot and build documentation
This commit is contained in:
commit
73401987df
17 files changed
+2169
No files matched your search
@@ -0,0 +1,6 @@
|
||||
build*/
|
||||
runtime/
|
||||
vendor/sodium/
|
||||
*.user
|
||||
*.nrb
|
||||
*.log
|
||||
@@ -0,0 +1,47 @@
|
||||
# Building Nerbus Wallet
|
||||
|
||||
## Windows x64
|
||||
|
||||
Install Visual Studio C++ Build Tools, Qt 6.6.2 MSVC x64, CMake, and Ninja.
|
||||
From PowerShell in this repository, download the pinned libsodium dependency:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/fetch-sodium.ps1
|
||||
```
|
||||
|
||||
From an x64 Visual Studio developer prompt:
|
||||
|
||||
```bat
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=C:/Qt/6.6.2/msvc2019_64
|
||||
cmake --build build --parallel 2
|
||||
C:/Qt/6.6.2/msvc2019_64/bin/windeployqt.exe --release --compiler-runtime build/nerbus-qt.exe
|
||||
copy nerbus.svg build\nerbus.svg
|
||||
```
|
||||
|
||||
Copy the separately built `nerbus-wallet-core.exe` beside `nerbus-qt.exe`.
|
||||
`libsodium.dll` is copied by CMake. Distribute the complete runtime folder.
|
||||
|
||||
## Linux x86_64
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential cmake pkg-config qt6-base-dev libqt6svg6-dev libsodium-dev
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build -j2
|
||||
cp nerbus.svg build/
|
||||
```
|
||||
|
||||
Place a native `nerbus-wallet-core` beside `nerbus-qt`. Install Qt runtime
|
||||
dependencies or bundle them with the desktop application. The prepared Linux
|
||||
package targets Ubuntu 22.04 / glibc 2.35 and uses X11 or XWayland.
|
||||
|
||||
## Offline signing helper
|
||||
|
||||
Clone and build [Nerbus Core](https://git.nerbus.com/nerbus/nerbus) for the target
|
||||
platform first. `core/nerbus-wallet-core.cpp` replaces the transaction utility's
|
||||
entry point and includes its original source. The detailed procedure is in
|
||||
[BUILD.txt](BUILD.txt), section OFFLINE NRB CORE COMPONENT. Keep this helper
|
||||
separate from the standard `nerbus-tx` utility and restore the original source
|
||||
and utility after compiling it. Windows dependencies must be built with MinGW;
|
||||
the GUI uses MSVC, and they communicate over an anonymous pipe.
|
||||
|
||||
The server bridge is in `server/`. It is not required for local address creation.
|
||||
@@ -0,0 +1,45 @@
|
||||
NERBUS WALLET BUILD
|
||||
|
||||
Windows x64 prerequisites:
|
||||
Visual Studio 2022 C++ Build Tools
|
||||
Qt 6.6.2 msvc2019_64 (Widgets, Network, Svg)
|
||||
CMake and Ninja
|
||||
|
||||
The supplied vendor/sodium directory contains official libsodium 1.0.22 MSVC
|
||||
headers, import library, and DLL. Download archive:
|
||||
https://github.com/jedisct1/libsodium/releases/download/1.0.22-RELEASE/libsodium-1.0.22-msvc.zip
|
||||
SHA256: 3e03a726fac4bc09cb61d8f29d658ef7a5eca0811de59082130414f7ca2e4279
|
||||
|
||||
From an x64 Visual Studio developer prompt, with CMake and Ninja on PATH:
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=C:/Qt/6.6.2/msvc2019_64
|
||||
cmake --build build --parallel 2
|
||||
C:/Qt/6.6.2/msvc2019_64/bin/windeployqt.exe --release --compiler-runtime build/nerbus-qt.exe
|
||||
copy runtime\nerbus-wallet-core.exe build\nerbus-wallet-core.exe
|
||||
copy nerbus.svg build\nerbus.svg
|
||||
|
||||
The desktop Nerbus-Build-Windows.bat uses the paths installed on this machine
|
||||
and also copies all runtime libraries and updates the desktop shortcut.
|
||||
|
||||
OFFLINE NRB CORE COMPONENT
|
||||
core/nerbus-wallet-core.cpp uses the existing NRB transaction utility code.
|
||||
It is compiled with the configured NRB cross-build's nerbus-tx target and its
|
||||
existing dependencies. It does not build or start a daemon.
|
||||
|
||||
In a configured NRB source tree, save src/komodo-tx.cpp as
|
||||
src/nerbus-original-tx.inc, then put core/nerbus-wallet-core.cpp in
|
||||
src/komodo-tx.cpp. Run:
|
||||
make -C src -j2 nerbus-tx.exe
|
||||
cp src/nerbus-tx.exe /your/output/nerbus-wallet-core.exe
|
||||
x86_64-w64-mingw32-strip /your/output/nerbus-wallet-core.exe
|
||||
|
||||
Restore src/komodo-tx.cpp from src/nerbus-original-tx.inc immediately after
|
||||
building, touch src/komodo-tx.cpp, and rebuild nerbus-tx.exe to restore the
|
||||
standard utility in that build tree. Keep the offline component separately.
|
||||
For Linux, use a native configured tree and target nerbus-tx without .exe.
|
||||
|
||||
Obtain the full NRB source from https://git.nerbus.com/nerbus/nerbus. The existing user-modified Qt main.cpp is not used by this project.
|
||||
|
||||
LINUX GUI
|
||||
Install Qt6 Widgets/Network/Svg development packages, CMake, and libsodium-dev.
|
||||
Configure and build with CMake, then place a native nerbus-wallet-core binary
|
||||
and nerbus.svg beside nerbus-qt. No local coin daemon is required.
|
||||
@@ -0,0 +1,26 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(NerbusWallet VERSION 0.2.1 LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Network Svg)
|
||||
if(WIN32)
|
||||
add_library(sodium SHARED IMPORTED)
|
||||
set_target_properties(sodium PROPERTIES
|
||||
IMPORTED_IMPLIB "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sodium/lib/libsodium.lib"
|
||||
IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sodium/bin/libsodium.dll"
|
||||
INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sodium/include")
|
||||
else()
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(SODIUM REQUIRED IMPORTED_TARGET libsodium)
|
||||
add_library(sodium ALIAS PkgConfig::SODIUM)
|
||||
endif()
|
||||
qt_add_executable(nerbus-qt WIN32 wallet_app.cpp wallet_store.cpp wallet_store.h)
|
||||
target_link_libraries(nerbus-qt PRIVATE Qt6::Widgets Qt6::Network Qt6::Svg sodium)
|
||||
if(MSVC)
|
||||
target_compile_options(nerbus-qt PRIVATE /utf-8 /W4)
|
||||
endif()
|
||||
if(WIN32)
|
||||
add_custom_command(TARGET nerbus-qt POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:sodium> $<TARGET_FILE_DIR:nerbus-qt>)
|
||||
endif()
|
||||
install(TARGETS nerbus-qt RUNTIME DESTINATION .)
|
||||
@@ -0,0 +1,49 @@
|
||||
Copyright (c) 2026 Nerbus contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
The MIT software license (https://www.opensource.org/licenses/mit-license.php)
|
||||
above applies to the code directly included in this source distribution, with
|
||||
the exception of certain Autoconf macros. Dependencies downloaded as part of
|
||||
the build process may be covered by other open-source licenses. The MIT-licensed
|
||||
source code is not considered a derived work of these Autoconf macros or of the
|
||||
dependencies. For further details see 'contrib/debian/copyright'.
|
||||
|
||||
|
||||
This product includes software developed by the OpenSSL Project for use in the
|
||||
OpenSSL Toolkit (https://www.openssl.org/). This product includes cryptographic
|
||||
software written by Eric Young (eay@cryptsoft.com).
|
||||
|
||||
|
||||
Although almost all of the Zcash/Komodo/VerusCoin code is licensed under "permissive" open source
|
||||
licenses, users and distributors should note that when built using the default
|
||||
build options, Verus depends on Oracle Berkeley DB 6.2.x, which is licensed
|
||||
under the GNU Affero General Public License. Additionally, src/pbaas/shuffle_compat.h, which
|
||||
is used solely for Apple Mac platform builds, contains source code derived from GCC libstdc++
|
||||
(Copyright 2009-2023 Free Software Foundation) for consensus compatibility when building
|
||||
without libstdc++, also governed by GPLv3.
|
||||
|
||||
Contributors should understand licensing implications before modifying the
|
||||
following files in build-aux/m4 (see https://github.com/zcash/zcash/issues/2827):
|
||||
|
||||
* ax_check_compile_flag.m4
|
||||
* ax_check_link_flag.m4
|
||||
* ax_check_preproc_flag.m4
|
||||
* ax_pthread.m4
|
||||
@@ -0,0 +1,29 @@
|
||||
# Nerbus Wallet
|
||||
|
||||
Nerbus Wallet 0.2.1 is a Qt desktop wallet for transparent NRB addresses.
|
||||
Private keys and transaction signatures stay on the user's computer. Receiving
|
||||
addresses can be created offline without a local daemon or mandatory password.
|
||||
Password encryption can be enabled in **Wallet & backup**.
|
||||
|
||||
Balance, public address history, spendable outputs, and signed transaction
|
||||
broadcasts use `https://explorer.nerbus.com/wallet/v1`. This is a lightweight
|
||||
wallet; it relies on that server for network data rather than validating the
|
||||
whole chain. Shielded transfers, staking, and identity operations are outside
|
||||
the current interface.
|
||||
|
||||
The current application entry point is `wallet_app.cpp`. Key storage is in
|
||||
`wallet_store.cpp`. The offline signing helper in `core/` uses the Nerbus Core
|
||||
cryptographic and transaction implementation. It does not start a node.
|
||||
|
||||
Read [README.txt](README.txt) for wallet files and backups, and
|
||||
[BUILD.md](BUILD.md) for Windows and Linux build instructions.
|
||||
New wallets are unencrypted unless the owner enables a password. Back up again
|
||||
after adding addresses; older backups do not contain later keys.
|
||||
|
||||
Windows and Linux packages are being prepared separately. No binaries or
|
||||
release assets are included in this source repository.
|
||||
|
||||
## License
|
||||
|
||||
See `COPYING`, `core/COPYING`, and the original source headers. Qt and libsodium
|
||||
retain their respective licenses. Core source: https://git.nerbus.com/nerbus/nerbus
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
NERBUS WALLET 0.2.1
|
||||
|
||||
Open nerbus-qt.exe or the Nerbus Wallet desktop shortcut.
|
||||
Keep the complete application folder together.
|
||||
|
||||
RECEIVING
|
||||
Open Receive and click New receiving address. A new wallet is created without
|
||||
a password or setup dialog. The private key is generated locally with the NRB core cryptographic
|
||||
code and saved before the receiving address is displayed. Address creation
|
||||
does not require a daemon, network connection, blockchain download, or Sapling
|
||||
parameter files. No address-created confirmation dialog is shown.
|
||||
|
||||
KEYS AND BACKUPS
|
||||
The default wallet file on Windows is:
|
||||
%APPDATA%\Nerbus\Wallet\wallet.nrb
|
||||
Password protection is optional and off by default. A wallet without a password
|
||||
opens automatically, does not lock after inactivity, and stores private keys in
|
||||
an unencrypted file. Its backups also contain unencrypted private keys.
|
||||
|
||||
Use Wallet & backup > Set password (optional) to enable Argon2id13 (3 passes,
|
||||
64 MiB) and XChaCha20-Poly1305 encryption. A password-protected wallet locks
|
||||
after 10 minutes without keyboard or mouse activity. Use Remove password to
|
||||
turn protection off while keeping all addresses and keys. For an existing
|
||||
locked wallet, enter its current password once to remove protection.
|
||||
Changing protection does not modify existing backup files.
|
||||
|
||||
Use Wallet & backup > Back up wallet after creating or importing
|
||||
addresses. This is a non-deterministic key wallet: an older backup does not
|
||||
contain addresses created later. Keep the backup safe and retain its password
|
||||
if it was encrypted. There is no password reset or server-side key recovery.
|
||||
|
||||
Restore a .nrb backup before creating a new wallet, or launch with
|
||||
--wallet-dir=<new folder> to restore into a separate location. Existing wallets
|
||||
are never overwritten by Restore. Only encrypted backups require a password.
|
||||
Private keys can also be imported/exported
|
||||
in NRB WIF format through Wallet & backup.
|
||||
|
||||
Existing daemon wallet.dat files and the old %APPDATA%\Nerbus\NRB directory are
|
||||
not modified or automatically migrated. A new wallet has different addresses.
|
||||
To use a previous transparent address, import its private key from the original
|
||||
wallet. Keep original wallet backups until migration is complete.
|
||||
|
||||
NETWORK
|
||||
Balance, recent transaction IDs, spendable outputs, and broadcasting use:
|
||||
https://explorer.nerbus.com/wallet/v1
|
||||
The server receives public addresses and signed transactions only. Signing is
|
||||
performed locally by nerbus-wallet-core. The client verifies the NRB genesis
|
||||
identity, previous transaction hashes and output values, key ownership, change
|
||||
ownership, and all payment signatures before submission.
|
||||
|
||||
This is a lightweight wallet: chain data comes from the configured server;
|
||||
it does not independently validate the full blockchain. Transparent standard
|
||||
P2PKH addresses are supported. Shielded transfers, staking, identities, and
|
||||
multisignature outputs are not supported by this interface.
|
||||
|
||||
When the server node is stopped or still syncing, addresses and backups remain
|
||||
available. Balance and payments become available when the server node is ready.
|
||||
Coinbase outputs are conservatively held until 150 confirmations. The default
|
||||
fee is 0.00010000 NRB and can be changed before reviewing a payment.
|
||||
|
||||
Signed payment receipts are saved beside the wallet under payments/. If a
|
||||
submission times out, check its transaction ID in the explorer before sending
|
||||
again. Receipts contain public transaction data, not private keys.
|
||||
|
||||
The application never starts nerbusd. The offline signing component accepts
|
||||
one JSON request over an anonymous pipe and exits; it has no RPC listener.
|
||||
|
||||
BUILD
|
||||
See BUILD.txt. Source is included in source/ in the desktop package.
|
||||
@@ -0,0 +1,53 @@
|
||||
Copyright (c) 2009-2017 The Bitcoin Core developers
|
||||
Copyright (c) 2009-2018 Bitcoin Developers
|
||||
Copyright (c) 2016-2024 The Zcash developers
|
||||
Copyright (c) 2016-2025 The Komodo developers
|
||||
Copyright (c) 2018-2025 The VerusCoin developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
The MIT software license (https://www.opensource.org/licenses/mit-license.php)
|
||||
above applies to the code directly included in this source distribution, with
|
||||
the exception of certain Autoconf macros. Dependencies downloaded as part of
|
||||
the build process may be covered by other open-source licenses. The MIT-licensed
|
||||
source code is not considered a derived work of these Autoconf macros or of the
|
||||
dependencies. For further details see 'contrib/debian/copyright'.
|
||||
|
||||
|
||||
This product includes software developed by the OpenSSL Project for use in the
|
||||
OpenSSL Toolkit (https://www.openssl.org/). This product includes cryptographic
|
||||
software written by Eric Young (eay@cryptsoft.com).
|
||||
|
||||
|
||||
Although almost all of the Zcash/Komodo/VerusCoin code is licensed under "permissive" open source
|
||||
licenses, users and distributors should note that when built using the default
|
||||
build options, Verus depends on Oracle Berkeley DB 6.2.x, which is licensed
|
||||
under the GNU Affero General Public License. Additionally, src/pbaas/shuffle_compat.h, which
|
||||
is used solely for Apple Mac platform builds, contains source code derived from GCC libstdc++
|
||||
(Copyright 2009-2023 Free Software Foundation) for consensus compatibility when building
|
||||
without libstdc++, also governed by GPLv3.
|
||||
|
||||
Contributors should understand licensing implications before modifying the
|
||||
following files in build-aux/m4 (see https://github.com/zcash/zcash/issues/2827):
|
||||
|
||||
* ax_check_compile_flag.m4
|
||||
* ax_check_link_flag.m4
|
||||
* ax_check_preproc_flag.m4
|
||||
* ax_pthread.m4
|
||||
@@ -0,0 +1,729 @@
|
||||
// Copyright (c) 2009-2014 The Bitcoin Core developers
|
||||
// Distributed under the MIT software license, see the accompanying
|
||||
// file COPYING or https://www.opensource.org/licenses/mit-license.php .
|
||||
|
||||
#include "clientversion.h"
|
||||
#include "coins.h"
|
||||
#include "consensus/consensus.h"
|
||||
#include "consensus/upgrades.h"
|
||||
#include "core_io.h"
|
||||
#include "key_io.h"
|
||||
#include "keystore.h"
|
||||
#include "primitives/transaction.h"
|
||||
#include "script/script.h"
|
||||
#include "pbaas/pbaas.h"
|
||||
#include "script/sign.h"
|
||||
#include <univalue.h>
|
||||
#include "util.h"
|
||||
#include "utilmoneystr.h"
|
||||
#include "utilstrencodings.h"
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
#include <boost/algorithm/string.hpp>
|
||||
#include <boost/assign/list_of.hpp>
|
||||
|
||||
using namespace std;
|
||||
|
||||
#include "uint256.h"
|
||||
#include "arith_uint256.h"
|
||||
#include "komodo_structs.h"
|
||||
#include "komodo_globals.h"
|
||||
#include "komodo_defs.h"
|
||||
|
||||
#include "komodo_interest.h"
|
||||
|
||||
struct CCcontract_info *CCinit(struct CCcontract_info *cp, uint8_t evalcode)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint64_t komodo_accrued_interest(int32_t *txheightp,uint32_t *locktimep,uint256 hash,int32_t n,int32_t checkheight,uint64_t checkvalue,int32_t tipheight)
|
||||
{
|
||||
return(0);
|
||||
}
|
||||
|
||||
CConnectedChains ConnectedChains;
|
||||
CCurrencyDefinition CConnectedChains::GetCachedCurrency(const uint160 ¤cyID)
|
||||
{
|
||||
assert(false);
|
||||
return CCurrencyDefinition();
|
||||
}
|
||||
|
||||
// unused in client, but here for linking
|
||||
bool fIdIndex;
|
||||
bool fConversionIndex;
|
||||
uint160 CIdentity::IdentityPrimaryAddressKey(const CTxDestination &dest)
|
||||
{
|
||||
assert(false);
|
||||
return uint160();
|
||||
}
|
||||
|
||||
static bool fCreateBlank;
|
||||
static std::map<std::string,UniValue> registers;
|
||||
static const int CONTINUE_EXECUTION=-1;
|
||||
boost::optional<libzcash::SaplingPaymentAddress> defaultSaplingDest;
|
||||
|
||||
//
|
||||
// This function returns either one of EXIT_ codes when it's expected to stop the process or
|
||||
// CONTINUE_EXECUTION when it's expected to continue further.
|
||||
//
|
||||
static int AppInitRawTx(int argc, char* argv[])
|
||||
{
|
||||
//
|
||||
// Parameters
|
||||
//
|
||||
ParseParameters(argc, argv);
|
||||
|
||||
// Check for -testnet or -regtest parameter (Params() calls are only valid after this clause)
|
||||
if (!SelectParamsFromCommandLine()) {
|
||||
fprintf(stderr, "Error: Invalid combination of -regtest and -testnet.\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
fCreateBlank = GetBoolArg("-create", false);
|
||||
|
||||
if (argc<2 || IsArgSet("-?") || IsArgSet("-h") || IsArgSet("-help"))
|
||||
{
|
||||
// First part of help message is specific to this utility
|
||||
std::string strUsage = _("Nerbus nerbus-tx utility version") + " " + FormatFullVersion() + "\n\n" +
|
||||
_("Usage:") + "\n" +
|
||||
" nerbus-tx [options] <hex-tx> [commands] " + _("Update hex-encoded Nerbus transaction") + "\n" +
|
||||
" nerbus-tx [options] -create [commands] " + _("Create hex-encoded Nerbus transaction") + "\n" +
|
||||
"\n";
|
||||
|
||||
fprintf(stdout, "%s", strUsage.c_str());
|
||||
|
||||
strUsage = HelpMessageGroup(_("Options:"));
|
||||
strUsage += HelpMessageOpt("-?", _("This help message"));
|
||||
strUsage += HelpMessageOpt("-create", _("Create new, empty TX."));
|
||||
strUsage += HelpMessageOpt("-json", _("Select JSON output"));
|
||||
strUsage += HelpMessageOpt("-txid", _("Output only the hex-encoded transaction id of the resultant transaction."));
|
||||
strUsage += HelpMessageOpt("-regtest", _("Enter regression test mode, which uses a special chain in which blocks can be solved instantly."));
|
||||
strUsage += HelpMessageOpt("-testnet", _("Use the test network"));
|
||||
|
||||
fprintf(stdout, "%s", strUsage.c_str());
|
||||
|
||||
strUsage = HelpMessageGroup(_("Commands:"));
|
||||
strUsage += HelpMessageOpt("delin=N", _("Delete input N from TX"));
|
||||
strUsage += HelpMessageOpt("delout=N", _("Delete output N from TX"));
|
||||
strUsage += HelpMessageOpt("in=TXID:VOUT(:SEQUENCE_NUMBER)", _("Add input to TX"));
|
||||
strUsage += HelpMessageOpt("locktime=N", _("Set TX lock time to N"));
|
||||
strUsage += HelpMessageOpt("nversion=N", _("Set TX version to N"));
|
||||
strUsage += HelpMessageOpt("outaddr=VALUE:ADDRESS", _("Add address-based output to TX"));
|
||||
strUsage += HelpMessageOpt("outscript=VALUE:SCRIPT", _("Add raw script output to TX"));
|
||||
strUsage += HelpMessageOpt("sign=HEIGHT:SIGHASH-FLAGS", _("Add zero or more signatures to transaction") + ". " +
|
||||
_("This command requires JSON registers:") +
|
||||
_("prevtxs=JSON object") + ", " +
|
||||
_("privatekeys=JSON object") + ". " +
|
||||
_("See signrawtransaction docs for format of sighash flags, JSON objects."));
|
||||
fprintf(stdout, "%s", strUsage.c_str());
|
||||
|
||||
strUsage = HelpMessageGroup(_("Register Commands:"));
|
||||
strUsage += HelpMessageOpt("load=NAME:FILENAME", _("Load JSON file FILENAME into register NAME"));
|
||||
strUsage += HelpMessageOpt("set=NAME:JSON-STRING", _("Set register NAME to given JSON-STRING"));
|
||||
fprintf(stdout, "%s", strUsage.c_str());
|
||||
|
||||
if (argc < 2) {
|
||||
fprintf(stderr, "Error: too few parameters\n");
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
return CONTINUE_EXECUTION;
|
||||
}
|
||||
|
||||
static void RegisterSetJson(const std::string& key, const std::string& rawJson)
|
||||
{
|
||||
UniValue val;
|
||||
if (!val.read(rawJson)) {
|
||||
std::string strErr = "Cannot parse JSON for key " + key;
|
||||
throw std::runtime_error(strErr);
|
||||
}
|
||||
|
||||
registers[key] = val;
|
||||
}
|
||||
|
||||
static void RegisterSet(const std::string& strInput)
|
||||
{
|
||||
// separate NAME:VALUE in string
|
||||
size_t pos = strInput.find(':');
|
||||
if ((pos == std::string::npos) ||
|
||||
(pos == 0) ||
|
||||
(pos == (strInput.size() - 1)))
|
||||
throw std::runtime_error("Register input requires NAME:VALUE");
|
||||
|
||||
std::string key = strInput.substr(0, pos);
|
||||
std::string valStr = strInput.substr(pos + 1, std::string::npos);
|
||||
|
||||
RegisterSetJson(key, valStr);
|
||||
}
|
||||
|
||||
static void RegisterLoad(const std::string& strInput)
|
||||
{
|
||||
// separate NAME:FILENAME in string
|
||||
size_t pos = strInput.find(':');
|
||||
if ((pos == std::string::npos) ||
|
||||
(pos == 0) ||
|
||||
(pos == (strInput.size() - 1)))
|
||||
throw std::runtime_error("Register load requires NAME:FILENAME");
|
||||
|
||||
std::string key = strInput.substr(0, pos);
|
||||
std::string filename = strInput.substr(pos + 1, std::string::npos);
|
||||
|
||||
FILE *f = fopen(filename.c_str(), "r");
|
||||
if (!f) {
|
||||
std::string strErr = "Cannot open file " + filename;
|
||||
throw std::runtime_error(strErr);
|
||||
}
|
||||
|
||||
// load file chunks into one big buffer
|
||||
std::string valStr;
|
||||
while ((!feof(f)) && (!ferror(f))) {
|
||||
char buf[4096];
|
||||
int bread = fread(buf, 1, sizeof(buf), f);
|
||||
if (bread <= 0)
|
||||
break;
|
||||
|
||||
valStr.insert(valStr.size(), buf, bread);
|
||||
}
|
||||
|
||||
int error = ferror(f);
|
||||
fclose(f);
|
||||
|
||||
if (error) {
|
||||
std::string strErr = "Error reading file " + filename;
|
||||
throw std::runtime_error(strErr);
|
||||
}
|
||||
|
||||
// evaluate as JSON buffer register
|
||||
RegisterSetJson(key, valStr);
|
||||
}
|
||||
|
||||
static void MutateTxVersion(CMutableTransaction& tx, const std::string& cmdVal)
|
||||
{
|
||||
int64_t newVersion = atoi64(cmdVal);
|
||||
if (newVersion < CTransaction::SPROUT_MIN_CURRENT_VERSION || newVersion > CTransaction::SPROUT_MAX_CURRENT_VERSION)
|
||||
throw std::runtime_error("Invalid TX version requested");
|
||||
|
||||
tx.nVersion = (int) newVersion;
|
||||
}
|
||||
|
||||
static void MutateTxExpiry(CMutableTransaction& tx, const std::string& cmdVal)
|
||||
{
|
||||
int64_t newExpiry = atoi64(cmdVal);
|
||||
if (newExpiry <= 0 || newExpiry >= TX_EXPIRY_HEIGHT_THRESHOLD) {
|
||||
throw std::runtime_error("Invalid TX expiry requested");
|
||||
}
|
||||
tx.nExpiryHeight = (int) newExpiry;
|
||||
}
|
||||
|
||||
static void MutateTxLocktime(CMutableTransaction& tx, const std::string& cmdVal)
|
||||
{
|
||||
int64_t newLocktime = atoi64(cmdVal);
|
||||
if (newLocktime < 0LL || newLocktime > 0xffffffffLL)
|
||||
throw std::runtime_error("Invalid TX locktime requested");
|
||||
|
||||
tx.nLockTime = (unsigned int) newLocktime;
|
||||
}
|
||||
|
||||
static void MutateTxAddInput(CMutableTransaction& tx, const std::string& strInput)
|
||||
{
|
||||
std::vector<std::string> vStrInputParts;
|
||||
boost::split(vStrInputParts, strInput, boost::is_any_of(":"));
|
||||
|
||||
// separate TXID:VOUT in string
|
||||
if (vStrInputParts.size()<2)
|
||||
throw std::runtime_error("TX input missing separator");
|
||||
|
||||
// extract and validate TXID
|
||||
std::string strTxid = vStrInputParts[0];
|
||||
if ((strTxid.size() != 64) || !IsHex(strTxid))
|
||||
throw std::runtime_error("invalid TX input txid");
|
||||
uint256 txid(uint256S(strTxid));
|
||||
|
||||
static const unsigned int minTxOutSz = 9;
|
||||
static const unsigned int maxVout = MAX_BLOCK_SIZE / minTxOutSz;
|
||||
|
||||
// extract and validate vout
|
||||
std::string strVout = vStrInputParts[1];
|
||||
int vout = atoi(strVout);
|
||||
if ((vout < 0) || (vout > (int)maxVout))
|
||||
throw std::runtime_error("invalid TX input vout");
|
||||
|
||||
// extract the optional sequence number
|
||||
uint32_t nSequenceIn=std::numeric_limits<unsigned int>::max();
|
||||
if (vStrInputParts.size() > 2)
|
||||
nSequenceIn = atoi(vStrInputParts[2]);
|
||||
|
||||
// append to transaction input list
|
||||
CTxIn txin(txid, vout, CScript(), nSequenceIn);
|
||||
tx.vin.push_back(txin);
|
||||
}
|
||||
|
||||
static void MutateTxAddOutAddr(CMutableTransaction& tx, const std::string& strInput)
|
||||
{
|
||||
// separate VALUE:ADDRESS in string
|
||||
size_t pos = strInput.find(':');
|
||||
if ((pos == std::string::npos) ||
|
||||
(pos == 0) ||
|
||||
(pos == (strInput.size() - 1)))
|
||||
throw std::runtime_error("TX output missing separator");
|
||||
|
||||
// extract and validate VALUE
|
||||
std::string strValue = strInput.substr(0, pos);
|
||||
CAmount value;
|
||||
if (!ParseMoney(strValue, value))
|
||||
throw std::runtime_error("invalid TX output value");
|
||||
|
||||
// extract and validate ADDRESS
|
||||
std::string strAddr = strInput.substr(pos + 1, std::string::npos);
|
||||
CTxDestination destination = DecodeDestination(strAddr);
|
||||
if (!IsValidDestination(destination)) {
|
||||
throw std::runtime_error("invalid TX output address");
|
||||
}
|
||||
CScript scriptPubKey = GetScriptForDestination(destination);
|
||||
|
||||
// construct TxOut, append to transaction output list
|
||||
CTxOut txout(value, scriptPubKey);
|
||||
tx.vout.push_back(txout);
|
||||
}
|
||||
|
||||
static void MutateTxAddOutScript(CMutableTransaction& tx, const std::string& strInput)
|
||||
{
|
||||
// separate VALUE:SCRIPT in string
|
||||
size_t pos = strInput.find(':');
|
||||
if ((pos == std::string::npos) ||
|
||||
(pos == 0))
|
||||
throw std::runtime_error("TX output missing separator");
|
||||
|
||||
// extract and validate VALUE
|
||||
std::string strValue = strInput.substr(0, pos);
|
||||
CAmount value;
|
||||
if (!ParseMoney(strValue, value))
|
||||
throw std::runtime_error("invalid TX output value");
|
||||
|
||||
// extract and validate script
|
||||
std::string strScript = strInput.substr(pos + 1, std::string::npos);
|
||||
CScript scriptPubKey = ParseScript(strScript); // throws on err
|
||||
|
||||
// construct TxOut, append to transaction output list
|
||||
CTxOut txout(value, scriptPubKey);
|
||||
tx.vout.push_back(txout);
|
||||
}
|
||||
|
||||
static void MutateTxDelInput(CMutableTransaction& tx, const std::string& strInIdx)
|
||||
{
|
||||
// parse requested deletion index
|
||||
int inIdx = atoi(strInIdx);
|
||||
if (inIdx < 0 || inIdx >= (int)tx.vin.size()) {
|
||||
std::string strErr = "Invalid TX input index '" + strInIdx + "'";
|
||||
throw std::runtime_error(strErr.c_str());
|
||||
}
|
||||
|
||||
// delete input from transaction
|
||||
tx.vin.erase(tx.vin.begin() + inIdx);
|
||||
}
|
||||
|
||||
static void MutateTxDelOutput(CMutableTransaction& tx, const std::string& strOutIdx)
|
||||
{
|
||||
// parse requested deletion index
|
||||
int outIdx = atoi(strOutIdx);
|
||||
if (outIdx < 0 || outIdx >= (int)tx.vout.size()) {
|
||||
std::string strErr = "Invalid TX output index '" + strOutIdx + "'";
|
||||
throw std::runtime_error(strErr.c_str());
|
||||
}
|
||||
|
||||
// delete output from transaction
|
||||
tx.vout.erase(tx.vout.begin() + outIdx);
|
||||
}
|
||||
|
||||
static const unsigned int N_SIGHASH_OPTS = 6;
|
||||
static const struct {
|
||||
const char *flagStr;
|
||||
int flags;
|
||||
} sighashOptions[N_SIGHASH_OPTS] = {
|
||||
{"ALL", SIGHASH_ALL},
|
||||
{"NONE", SIGHASH_NONE},
|
||||
{"SINGLE", SIGHASH_SINGLE},
|
||||
{"ALL|ANYONECANPAY", SIGHASH_ALL|SIGHASH_ANYONECANPAY},
|
||||
{"NONE|ANYONECANPAY", SIGHASH_NONE|SIGHASH_ANYONECANPAY},
|
||||
{"SINGLE|ANYONECANPAY", SIGHASH_SINGLE|SIGHASH_ANYONECANPAY},
|
||||
};
|
||||
|
||||
static bool findSighashFlags(int& flags, const std::string& flagStr)
|
||||
{
|
||||
flags = 0;
|
||||
|
||||
for (unsigned int i = 0; i < N_SIGHASH_OPTS; i++) {
|
||||
if (flagStr == sighashOptions[i].flagStr) {
|
||||
flags = sighashOptions[i].flags;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
uint256 ParseHashUO(std::map<std::string,UniValue>& o, std::string strKey)
|
||||
{
|
||||
if (!o.count(strKey))
|
||||
return uint256();
|
||||
return ParseHashUV(o[strKey], strKey);
|
||||
}
|
||||
|
||||
std::vector<unsigned char> ParseHexUO(std::map<std::string,UniValue>& o, std::string strKey)
|
||||
{
|
||||
if (!o.count(strKey)) {
|
||||
std::vector<unsigned char> emptyVec;
|
||||
return emptyVec;
|
||||
}
|
||||
return ParseHexUV(o[strKey], strKey);
|
||||
}
|
||||
|
||||
static void MutateTxSign(CMutableTransaction& tx, const std::string& strInput)
|
||||
{
|
||||
// separate HEIGHT:SIGHASH-FLAGS in string
|
||||
size_t pos = strInput.find(':');
|
||||
if ((pos == 0) ||
|
||||
(pos == (strInput.size() - 1)))
|
||||
throw std::runtime_error("Invalid sighash flag separator");
|
||||
|
||||
// extract and validate HEIGHT
|
||||
std::string strHeight = strInput.substr(0, pos);
|
||||
int nHeight = atoi(strHeight);
|
||||
if (nHeight <= 0) {
|
||||
throw std::runtime_error("invalid height");
|
||||
}
|
||||
|
||||
// extract and validate SIGHASH-FLAGS
|
||||
int nHashType = SIGHASH_ALL;
|
||||
std::string flagStr;
|
||||
if (pos != std::string::npos) {
|
||||
flagStr = strInput.substr(pos + 1, std::string::npos);
|
||||
}
|
||||
if (flagStr.size() > 0)
|
||||
if (!findSighashFlags(nHashType, flagStr))
|
||||
throw std::runtime_error("unknown sighash flag/sign option");
|
||||
|
||||
std::vector<CTransaction> txVariants;
|
||||
txVariants.push_back(tx);
|
||||
|
||||
// mergedTx will end up with all the signatures; it
|
||||
// starts as a clone of the raw tx:
|
||||
CMutableTransaction mergedTx(txVariants[0]);
|
||||
bool fComplete = true;
|
||||
CCoinsView viewDummy;
|
||||
CCoinsViewCache view(&viewDummy);
|
||||
|
||||
if (!registers.count("privatekeys"))
|
||||
throw std::runtime_error("privatekeys register variable must be set.");
|
||||
bool fGivenKeys = false;
|
||||
CBasicKeyStore tempKeystore;
|
||||
UniValue keysObj = registers["privatekeys"];
|
||||
fGivenKeys = true;
|
||||
|
||||
for (size_t kidx = 0; kidx < keysObj.size(); kidx++) {
|
||||
if (!keysObj[kidx].isStr())
|
||||
throw std::runtime_error("privatekey not a std::string");
|
||||
CKey key = DecodeSecret(keysObj[kidx].getValStr());
|
||||
if (!key.IsValid()) {
|
||||
throw std::runtime_error("privatekey not valid");
|
||||
}
|
||||
tempKeystore.AddKey(key);
|
||||
}
|
||||
|
||||
// Add previous txouts given in the RPC call:
|
||||
if (!registers.count("prevtxs"))
|
||||
throw std::runtime_error("prevtxs register variable must be set.");
|
||||
UniValue prevtxsObj = registers["prevtxs"];
|
||||
{
|
||||
for (size_t previdx = 0; previdx < prevtxsObj.size(); previdx++) {
|
||||
UniValue prevOut = prevtxsObj[previdx];
|
||||
if (!prevOut.isObject())
|
||||
throw std::runtime_error("expected prevtxs internal object");
|
||||
|
||||
std::map<std::string,UniValue::VType> types = boost::assign::map_list_of("txid", UniValue::VSTR)("vout",UniValue::VNUM)("scriptPubKey",UniValue::VSTR);
|
||||
if (!prevOut.checkObject(types))
|
||||
throw std::runtime_error("prevtxs internal object typecheck fail");
|
||||
|
||||
uint256 txid = ParseHashUV(prevOut["txid"], "txid");
|
||||
|
||||
int nOut = atoi(prevOut["vout"].getValStr());
|
||||
if (nOut < 0)
|
||||
throw std::runtime_error("vout must be positive");
|
||||
|
||||
std::vector<unsigned char> pkData(ParseHexUV(prevOut["scriptPubKey"], "scriptPubKey"));
|
||||
CScript scriptPubKey(pkData.begin(), pkData.end());
|
||||
|
||||
{
|
||||
CCoinsModifier coins = view.ModifyCoins(txid);
|
||||
if (coins->IsAvailable(nOut) && coins->vout[nOut].scriptPubKey != scriptPubKey) {
|
||||
std::string err("Previous output scriptPubKey mismatch:\n");
|
||||
err = err + ScriptToAsmStr(coins->vout[nOut].scriptPubKey) + "\nvs:\n"+
|
||||
ScriptToAsmStr(scriptPubKey);
|
||||
throw std::runtime_error(err);
|
||||
}
|
||||
if ((unsigned int)nOut >= coins->vout.size())
|
||||
coins->vout.resize(nOut+1);
|
||||
coins->vout[nOut].scriptPubKey = scriptPubKey;
|
||||
coins->vout[nOut].nValue = 0;
|
||||
if (prevOut.exists("amount")) {
|
||||
coins->vout[nOut].nValue = AmountFromValue(prevOut["amount"]);
|
||||
}
|
||||
}
|
||||
|
||||
// if redeemScript given and private keys given,
|
||||
// add redeemScript to the tempKeystore so it can be signed:
|
||||
if (fGivenKeys && scriptPubKey.IsPayToScriptHash() &&
|
||||
prevOut.exists("redeemScript")) {
|
||||
UniValue v = prevOut["redeemScript"];
|
||||
std::vector<unsigned char> rsData(ParseHexUV(v, "redeemScript"));
|
||||
CScript redeemScript(rsData.begin(), rsData.end());
|
||||
tempKeystore.AddCScript(redeemScript);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CKeyStore& keystore = tempKeystore;
|
||||
|
||||
bool fHashSingle = ((nHashType & ~SIGHASH_ANYONECANPAY) == SIGHASH_SINGLE);
|
||||
|
||||
// Grab the consensus branch ID for the given height
|
||||
auto consensusBranchId = CurrentEpochBranchId(nHeight, Params().GetConsensus());
|
||||
|
||||
// Sign what we can:
|
||||
for (unsigned int i = 0; i < mergedTx.vin.size(); i++) {
|
||||
CTxIn& txin = mergedTx.vin[i];
|
||||
const CCoins* coins = view.AccessCoins(txin.prevout.hash);
|
||||
if (!coins || !coins->IsAvailable(txin.prevout.n)) {
|
||||
fComplete = false;
|
||||
continue;
|
||||
}
|
||||
const CScript& prevPubKey = coins->vout[txin.prevout.n].scriptPubKey;
|
||||
const CAmount& amount = coins->vout[txin.prevout.n].nValue;
|
||||
|
||||
SignatureData sigdata;
|
||||
// Only sign SIGHASH_SINGLE if there's a corresponding output:
|
||||
if (!fHashSingle || (i < mergedTx.vout.size()))
|
||||
ProduceSignature(MutableTransactionSignatureCreator(&keystore, &mergedTx, i, amount, prevPubKey, nHeight, nHashType), prevPubKey, sigdata, consensusBranchId);
|
||||
|
||||
// ... and merge in other signatures:
|
||||
BOOST_FOREACH(const CTransaction& txv, txVariants)
|
||||
sigdata = CombineSignatures(prevPubKey, MutableTransactionSignatureChecker(&mergedTx, i, amount), sigdata, DataFromTransaction(txv, i), consensusBranchId);
|
||||
UpdateTransaction(mergedTx, i, sigdata);
|
||||
|
||||
if (!VerifyScript(txin.scriptSig, prevPubKey, STANDARD_SCRIPT_VERIFY_FLAGS, MutableTransactionSignatureChecker(&mergedTx, i, amount), consensusBranchId))
|
||||
fComplete = false;
|
||||
}
|
||||
|
||||
if (fComplete) {
|
||||
// do nothing... for now
|
||||
// perhaps store this for later optional JSON output
|
||||
}
|
||||
|
||||
tx = mergedTx;
|
||||
}
|
||||
|
||||
class Secp256k1Init
|
||||
{
|
||||
ECCVerifyHandle globalVerifyHandle;
|
||||
|
||||
public:
|
||||
Secp256k1Init() {
|
||||
ECC_Start();
|
||||
}
|
||||
~Secp256k1Init() {
|
||||
ECC_Stop();
|
||||
}
|
||||
};
|
||||
|
||||
static void MutateTx(CMutableTransaction& tx, const std::string& command,
|
||||
const std::string& commandVal)
|
||||
{
|
||||
boost::scoped_ptr<Secp256k1Init> ecc;
|
||||
|
||||
if (command == "nversion")
|
||||
MutateTxVersion(tx, commandVal);
|
||||
else if (command == "locktime")
|
||||
MutateTxLocktime(tx, commandVal);
|
||||
else if (command == "expiry")
|
||||
MutateTxExpiry(tx, commandVal);
|
||||
|
||||
else if (command == "delin")
|
||||
MutateTxDelInput(tx, commandVal);
|
||||
else if (command == "in")
|
||||
MutateTxAddInput(tx, commandVal);
|
||||
|
||||
else if (command == "delout")
|
||||
MutateTxDelOutput(tx, commandVal);
|
||||
else if (command == "outaddr")
|
||||
MutateTxAddOutAddr(tx, commandVal);
|
||||
else if (command == "outscript")
|
||||
MutateTxAddOutScript(tx, commandVal);
|
||||
|
||||
else if (command == "sign") {
|
||||
if (!ecc) { ecc.reset(new Secp256k1Init()); }
|
||||
MutateTxSign(tx, commandVal);
|
||||
}
|
||||
|
||||
else if (command == "load")
|
||||
RegisterLoad(commandVal);
|
||||
|
||||
else if (command == "set")
|
||||
RegisterSet(commandVal);
|
||||
|
||||
else
|
||||
throw std::runtime_error("unknown command");
|
||||
}
|
||||
|
||||
static void OutputTxJSON(const CTransaction& tx)
|
||||
{
|
||||
UniValue entry(UniValue::VOBJ);
|
||||
TxToUniv(tx, uint256(), entry);
|
||||
|
||||
std::string jsonOutput = entry.write(4);
|
||||
fprintf(stdout, "%s\n", jsonOutput.c_str());
|
||||
}
|
||||
|
||||
static void OutputTxHash(const CTransaction& tx)
|
||||
{
|
||||
std::string strHexHash = tx.GetHash().GetHex(); // the hex-encoded transaction hash (aka the transaction id)
|
||||
|
||||
fprintf(stdout, "%s\n", strHexHash.c_str());
|
||||
}
|
||||
|
||||
static void OutputTxHex(const CTransaction& tx)
|
||||
{
|
||||
std::string strHex = EncodeHexTx(tx);
|
||||
|
||||
fprintf(stdout, "%s\n", strHex.c_str());
|
||||
}
|
||||
|
||||
static void OutputTx(const CTransaction& tx)
|
||||
{
|
||||
if (GetBoolArg("-json", false))
|
||||
OutputTxJSON(tx);
|
||||
else if (GetBoolArg("-txid", false))
|
||||
OutputTxHash(tx);
|
||||
else
|
||||
OutputTxHex(tx);
|
||||
}
|
||||
|
||||
static std::string readStdin()
|
||||
{
|
||||
char buf[4096];
|
||||
std::string ret;
|
||||
|
||||
while (!feof(stdin)) {
|
||||
size_t bread = fread(buf, 1, sizeof(buf), stdin);
|
||||
ret.append(buf, bread);
|
||||
if (bread < sizeof(buf))
|
||||
break;
|
||||
}
|
||||
|
||||
if (ferror(stdin))
|
||||
throw std::runtime_error("error reading stdin");
|
||||
|
||||
boost::algorithm::trim_right(ret);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int CommandLineRawTx(int argc, char* argv[])
|
||||
{
|
||||
std::string strPrint;
|
||||
int nRet = 0;
|
||||
try {
|
||||
// Skip switches; Permit common stdin convention "-"
|
||||
while (argc > 1 && IsSwitchChar(argv[1][0]) &&
|
||||
(argv[1][1] != 0)) {
|
||||
argc--;
|
||||
argv++;
|
||||
}
|
||||
|
||||
CTransaction txDecodeTmp;
|
||||
int startArg;
|
||||
|
||||
if (!fCreateBlank) {
|
||||
// require at least one param
|
||||
if (argc < 2)
|
||||
throw std::runtime_error("too few parameters");
|
||||
|
||||
// param: hex-encoded bitcoin transaction
|
||||
std::string strHexTx(argv[1]);
|
||||
if (strHexTx == "-") // "-" implies standard input
|
||||
strHexTx = readStdin();
|
||||
|
||||
if (!DecodeHexTx(txDecodeTmp, strHexTx))
|
||||
throw std::runtime_error("invalid transaction encoding");
|
||||
|
||||
startArg = 2;
|
||||
} else
|
||||
startArg = 1;
|
||||
|
||||
CMutableTransaction tx(txDecodeTmp);
|
||||
|
||||
for (int i = startArg; i < argc; i++) {
|
||||
std::string arg = argv[i];
|
||||
std::string key, value;
|
||||
size_t eqpos = arg.find('=');
|
||||
if (eqpos == std::string::npos)
|
||||
key = arg;
|
||||
else {
|
||||
key = arg.substr(0, eqpos);
|
||||
value = arg.substr(eqpos + 1);
|
||||
}
|
||||
|
||||
MutateTx(tx, key, value);
|
||||
}
|
||||
|
||||
OutputTx(tx);
|
||||
}
|
||||
|
||||
catch (const boost::thread_interrupted&) {
|
||||
throw;
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
strPrint = std::string("error: ") + e.what();
|
||||
nRet = EXIT_FAILURE;
|
||||
}
|
||||
catch (...) {
|
||||
PrintExceptionContinue(NULL, "CommandLineRawTx()");
|
||||
throw;
|
||||
}
|
||||
|
||||
if (strPrint != "") {
|
||||
fprintf((nRet == 0 ? stdout : stderr), "%s\n", strPrint.c_str());
|
||||
}
|
||||
return nRet;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
SetupEnvironment();
|
||||
|
||||
try {
|
||||
int ret = AppInitRawTx(argc, argv);
|
||||
if (ret != CONTINUE_EXECUTION)
|
||||
return ret;
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
PrintExceptionContinue(&e, "AppInitRawTx()");
|
||||
return EXIT_FAILURE;
|
||||
} catch (...) {
|
||||
PrintExceptionContinue(NULL, "AppInitRawTx()");
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
int ret = EXIT_FAILURE;
|
||||
try {
|
||||
ret = CommandLineRawTx(argc, argv);
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
PrintExceptionContinue(&e, "CommandLineRawTx()");
|
||||
} catch (...) {
|
||||
PrintExceptionContinue(NULL, "CommandLineRawTx()");
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Offline NRB key generation and transparent transaction signing.
|
||||
// Build with the same flags and libraries as nerbus-tx. No RPC or node startup.
|
||||
#define main NerbusLegacyTransactionMain
|
||||
#include "nerbus-original-tx.inc"
|
||||
#undef main
|
||||
#include <sodium.h>
|
||||
#include <set>
|
||||
|
||||
static const int64_t WALLET_MAX_MONEY = 209000000LL * COIN;
|
||||
|
||||
static CAmount WalletAmount(const UniValue &v)
|
||||
{
|
||||
if (!v.isStr()) throw std::runtime_error("Amount must be a decimal string");
|
||||
CAmount n;
|
||||
if (!ParseMoney(v.get_str(), n) || n < 0 || n > WALLET_MAX_MONEY)
|
||||
throw std::runtime_error("Invalid NRB amount");
|
||||
return n;
|
||||
}
|
||||
|
||||
static UniValue WalletKey(const CKey &key)
|
||||
{
|
||||
if (!key.IsValid() || !key.IsCompressed())
|
||||
throw std::runtime_error("A compressed NRB private key is required");
|
||||
UniValue result(UniValue::VOBJ);
|
||||
result.pushKV("address", EncodeDestination(key.GetPubKey().GetID()));
|
||||
result.pushKV("privateKey", EncodeSecret(key));
|
||||
return result;
|
||||
}
|
||||
|
||||
static UniValue WalletPayment(const UniValue &request)
|
||||
{
|
||||
const auto &inputs = request["inputs"];
|
||||
const auto &keys = request["privateKeys"];
|
||||
if (!inputs.isArray() || inputs.empty() || inputs.size() > 100 ||
|
||||
!keys.isArray() || keys.empty() || keys.size() > 1000)
|
||||
throw std::runtime_error("Invalid payment inputs");
|
||||
int height = request["height"].get_int();
|
||||
if (height < 1 || height > 400000000)
|
||||
throw std::runtime_error("Invalid chain height");
|
||||
CAmount amount = WalletAmount(request["amount"]);
|
||||
CAmount fee = WalletAmount(request["fee"]);
|
||||
if (amount <= 0 || fee < 10000 || fee > COIN)
|
||||
throw std::runtime_error("Invalid payment amount or fee");
|
||||
CTxDestination to = DecodeDestination(request["to"].get_str());
|
||||
CTxDestination change = DecodeDestination(request["change"].get_str());
|
||||
if (!boost::get<CKeyID>(&to) || !boost::get<CKeyID>(&change))
|
||||
throw std::runtime_error("A transparent NRB address is required");
|
||||
CBasicKeyStore keystore;
|
||||
for (size_t i = 0; i < keys.size(); ++i) {
|
||||
CKey key = DecodeSecret(keys[i].get_str());
|
||||
if (!key.IsValid() || !key.IsCompressed())
|
||||
throw std::runtime_error("Invalid NRB private key");
|
||||
keystore.AddKey(key);
|
||||
}
|
||||
if (!keystore.HaveKey(*boost::get<CKeyID>(&change)))
|
||||
throw std::runtime_error("Change address is not owned by this wallet");
|
||||
|
||||
CMutableTransaction tx;
|
||||
tx.fOverwintered = true;
|
||||
tx.nVersion = SAPLING_TX_VERSION;
|
||||
tx.nVersionGroupId = SAPLING_VERSION_GROUP_ID;
|
||||
tx.nExpiryHeight = height + 40;
|
||||
tx.nLockTime = 0;
|
||||
CAmount total = 0;
|
||||
std::vector<CTxOut> previous;
|
||||
std::set<std::pair<uint256, unsigned int>> seen;
|
||||
for (size_t i = 0; i < inputs.size(); ++i) {
|
||||
const auto &in = inputs[i];
|
||||
std::string raw = in["raw"].get_str();
|
||||
if (raw.size() > 4000000) throw std::runtime_error("Previous transaction is too large");
|
||||
CTransaction prev;
|
||||
if (!DecodeHexTx(prev, raw)) throw std::runtime_error("Invalid previous transaction");
|
||||
uint256 txid = ParseHashUV(in["txid"], "txid");
|
||||
int index = in["vout"].get_int();
|
||||
if (prev.GetHash() != txid || index < 0 || (size_t)index >= prev.vout.size())
|
||||
throw std::runtime_error("Previous transaction does not match the selected input");
|
||||
if (!seen.emplace(txid, (unsigned int)index).second)
|
||||
throw std::runtime_error("Duplicate payment input");
|
||||
const CTxOut &out = prev.vout[index];
|
||||
CTxDestination dest;
|
||||
if (!ExtractDestination(out.scriptPubKey, dest) || !boost::get<CKeyID>(&dest) ||
|
||||
out.scriptPubKey != GetScriptForDestination(dest) ||
|
||||
!keystore.HaveKey(*boost::get<CKeyID>(&dest)))
|
||||
throw std::runtime_error("Input is not a standard address owned by this wallet");
|
||||
if (out.nValue != WalletAmount(in["amount"]) || out.nValue <= 0 ||
|
||||
total > WALLET_MAX_MONEY - out.nValue)
|
||||
throw std::runtime_error("Previous output amount does not match");
|
||||
total += out.nValue;
|
||||
previous.push_back(out);
|
||||
tx.vin.emplace_back(txid, (unsigned int)index);
|
||||
}
|
||||
if (total < amount + fee) throw std::runtime_error("Insufficient funds");
|
||||
CAmount changeAmount = total - amount - fee;
|
||||
if (changeAmount != 0 && changeAmount < 1000)
|
||||
throw std::runtime_error("Change is too small; adjust the amount or fee");
|
||||
tx.vout.emplace_back(amount, GetScriptForDestination(to));
|
||||
if (changeAmount) tx.vout.emplace_back(changeAmount, GetScriptForDestination(change));
|
||||
const auto branch = CurrentEpochBranchId(height, Params().GetConsensus());
|
||||
for (size_t i = 0; i < previous.size(); ++i) {
|
||||
SignatureData sig;
|
||||
const auto &prev = previous[i];
|
||||
if (!ProduceSignature(MutableTransactionSignatureCreator(&keystore, &tx, i,
|
||||
prev.nValue, prev.scriptPubKey, height, SIGHASH_ALL),
|
||||
prev.scriptPubKey, sig, branch))
|
||||
throw std::runtime_error("Unable to sign payment input");
|
||||
UpdateTransaction(tx, i, sig);
|
||||
if (!VerifyScript(tx.vin[i].scriptSig, prev.scriptPubKey, STANDARD_SCRIPT_VERIFY_FLAGS,
|
||||
MutableTransactionSignatureChecker(&tx, i, prev.nValue), branch))
|
||||
throw std::runtime_error("Payment signature verification failed");
|
||||
}
|
||||
CTransaction completed(tx);
|
||||
UniValue result(UniValue::VOBJ);
|
||||
result.pushKV("hex", EncodeHexTx(completed));
|
||||
result.pushKV("txid", completed.GetHash().GetHex());
|
||||
result.pushKV("fee", FormatMoney(fee));
|
||||
result.pushKV("amount", FormatMoney(amount));
|
||||
result.pushKV("change", FormatMoney(changeAmount));
|
||||
return result;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
// The only supported interface is a single bounded JSON request on stdin.
|
||||
// Private keys never appear in command-line arguments or temporary files.
|
||||
if (argc != 2 || std::string(argv[1]) != "-wallet-json") return EXIT_FAILURE;
|
||||
try {
|
||||
SetupEnvironment();
|
||||
ParseParameters(argc, argv);
|
||||
SelectParams(CBaseChainParams::MAIN);
|
||||
if (sodium_init() < 0) throw std::runtime_error("Secure randomness is unavailable");
|
||||
Secp256k1Init ellipticCurve;
|
||||
std::string raw;
|
||||
char buffer[4096];
|
||||
while (!feof(stdin)) {
|
||||
size_t n = fread(buffer, 1, sizeof(buffer), stdin);
|
||||
if (raw.size() + n > 16 * 1024 * 1024)
|
||||
throw std::runtime_error("Request is too large");
|
||||
raw.append(buffer, n);
|
||||
if (n < sizeof(buffer)) break;
|
||||
}
|
||||
if (ferror(stdin)) throw std::runtime_error("Unable to read request");
|
||||
UniValue request;
|
||||
if (!request.read(raw) || !request.isObject())
|
||||
throw std::runtime_error("Invalid request");
|
||||
sodium_memzero(&raw[0], raw.size());
|
||||
sodium_memzero(buffer, sizeof(buffer));
|
||||
UniValue result;
|
||||
std::string action = request["action"].get_str();
|
||||
if (action == "newKey") {
|
||||
CKey key;
|
||||
key.MakeNewKey(true);
|
||||
result = WalletKey(key);
|
||||
} else if (action == "importKey") {
|
||||
result = WalletKey(DecodeSecret(request["privateKey"].get_str()));
|
||||
} else if (action == "payment") {
|
||||
result = WalletPayment(request);
|
||||
} else throw std::runtime_error("Unknown wallet operation");
|
||||
std::string response = result.write();
|
||||
fwrite(response.data(), 1, response.size(), stdout);
|
||||
fputc('\n', stdout);
|
||||
fflush(stdout);
|
||||
sodium_memzero(&response[0], response.size());
|
||||
return EXIT_SUCCESS;
|
||||
} catch (const std::exception &e) {
|
||||
UniValue error(UniValue::VOBJ);
|
||||
error.pushKV("error", e.what());
|
||||
fprintf(stdout, "%s\n", error.write().c_str());
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="1" y="1" width="62" height="62" rx="2" fill="#f2eddf" stroke="#24231e" stroke-width="2"/><path d="M17 46V18h7l16 18V18h7v28h-7L24 28v18z" fill="#24231e"/></svg>
|
||||
|
After Width: | Height: | Size: 230 B |
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$temp = Join-Path ([System.IO.Path]::GetTempPath()) ('nerbus-sodium-' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $temp | Out-Null
|
||||
$archive = Join-Path $temp 'libsodium.zip'
|
||||
Invoke-WebRequest 'https://github.com/jedisct1/libsodium/releases/download/1.0.22-RELEASE/libsodium-1.0.22-msvc.zip' -OutFile $archive
|
||||
if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne '3e03a726fac4bc09cb61d8f29d658ef7a5eca0811de59082130414f7ca2e4279') { throw 'libsodium checksum mismatch' }
|
||||
Expand-Archive $archive -DestinationPath $temp
|
||||
$destination = Join-Path $root 'vendor/sodium'
|
||||
New-Item -ItemType Directory -Force -Path "$destination/lib","$destination/bin" | Out-Null
|
||||
Copy-Item "$temp/libsodium/include" $destination -Recurse -Force
|
||||
Copy-Item "$temp/libsodium/x64/Release/v143/dynamic/libsodium.lib" "$destination/lib/libsodium.lib" -Force
|
||||
Copy-Item "$temp/libsodium/x64/Release/v143/dynamic/libsodium.dll" "$destination/bin/libsodium.dll" -Force
|
||||
Write-Output 'Verified libsodium dependency installed.'
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Nerbus public wallet API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nerbus-explorer
|
||||
Group=nerbus-explorer
|
||||
ExecStart=/opt/nerbus-node/bin/node /opt/nerbus-wallet-api/wallet-api.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
CapabilityBoundingSet=
|
||||
MemoryMax=256M
|
||||
TasksMax=32
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
// A narrow public bridge. It never receives, stores, or handles private keys.
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const settings = JSON.parse(fs.readFileSync('/opt/nerbus-explorer/settings.json', 'utf8'));
|
||||
const GENESIS = '0177ee4010f9617feb8e2956aa4dfb7a8df18b6e4f6cc3359bdb8cfef9d10bfd';
|
||||
const MAX = 20900000000000000n;
|
||||
const COIN = 100000000n;
|
||||
let active = 0;
|
||||
function failure(message, status = 400) { const e = Error(message); e.status = status; return e; }
|
||||
function exactJson(text) {
|
||||
// Preserve decimal coin amounts and integer atomic units without JS floating point.
|
||||
return JSON.parse(text.replace(/("(?:\\.|[^"\\])*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(whole, quoted, number) => quoted || JSON.stringify(number)));
|
||||
}
|
||||
function integer(value) {
|
||||
if (!/^-?\d+$/.test(String(value))) throw failure('Invalid network amount.', 502);
|
||||
const n = BigInt(value);
|
||||
if (n < -MAX || n > MAX) throw failure('Network amount is outside the NRB range.', 502);
|
||||
return n;
|
||||
}
|
||||
function coins(value) {
|
||||
const s = String(value);
|
||||
if (!/^\d+(\.\d{1,8})?$/.test(s)) throw failure('Invalid output value.', 502);
|
||||
const [a, b = ''] = s.split('.'); return BigInt(a) * COIN + BigInt(b.padEnd(8, '0'));
|
||||
}
|
||||
function rpc(method, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = Buffer.from(JSON.stringify({jsonrpc:'1.0', id:'nrb-wallet', method, params}));
|
||||
const request = http.request({hostname:settings.wallet.host, port:settings.wallet.port, method:'POST',
|
||||
auth:settings.wallet.username + ':' + settings.wallet.password,
|
||||
headers:{'Content-Type':'application/json', 'Content-Length':data.length}}, response => {
|
||||
const parts = []; let size = 0;
|
||||
response.on('data', part => {
|
||||
size += part.length;
|
||||
if (size > 16 * 1024 * 1024) response.destroy(); else parts.push(part);
|
||||
});
|
||||
response.on('error', () => reject(failure('NRB network service is unavailable.', 503)));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const result = exactJson(Buffer.concat(parts).toString('utf8'));
|
||||
if (result.error) {
|
||||
const e = failure('The NRB node could not complete this request.', 503);
|
||||
e.rpcCode = Number(result.error.code);
|
||||
e.rpcMessage = String(result.error.message || '');
|
||||
reject(e);
|
||||
} else resolve(result.result);
|
||||
} catch (_) { reject(failure('Invalid NRB network response.', 502)); }
|
||||
});
|
||||
});
|
||||
request.setTimeout(12000, () => request.destroy());
|
||||
request.on('error', () => reject(failure('NRB network is unavailable. Receiving addresses can still be created locally.', 503)));
|
||||
request.end(data);
|
||||
});
|
||||
}
|
||||
function addresses(value) {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 1000 ||
|
||||
value.some(a => typeof a !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{26,36}$/.test(a)))
|
||||
throw failure('Invalid address list.');
|
||||
return [...new Set(value)];
|
||||
}
|
||||
async function chainStatus() {
|
||||
const hash = await rpc('getblockhash', [0]);
|
||||
if (hash !== GENESIS) throw failure('Network identity does not match NRB.', 503);
|
||||
const [height, peers] = await Promise.all([rpc('getblockcount'), rpc('getconnectioncount')]);
|
||||
let ready = false;
|
||||
if (Number(height) > 0 && Number(peers) > 0) {
|
||||
const tipHash = await rpc('getblockhash', [Number(height)]);
|
||||
const tip = await rpc('getblock', [tipHash]);
|
||||
ready = Math.abs(Date.now() / 1000 - Number(tip.time)) < 1800;
|
||||
}
|
||||
return {genesis:GENESIS, height:Number(height), ready};
|
||||
}
|
||||
async function account(request, state) {
|
||||
const owned = addresses(request.addresses);
|
||||
let balance, transactions;
|
||||
try {
|
||||
[balance, transactions] = await Promise.all([
|
||||
rpc('getaddressbalance', [{addresses:owned}]), rpc('getaddresstxids', [{addresses:owned}])]);
|
||||
} catch (e) {
|
||||
if (/addressindex|No information available/.test(e.rpcMessage || ''))
|
||||
throw failure('Address history is not available yet. The network index must finish syncing.', 503);
|
||||
throw e;
|
||||
}
|
||||
return {...state, balance:integer(balance.balance).toString(), transactions:transactions.slice(-30).reverse()};
|
||||
}
|
||||
async function inputs(request, state) {
|
||||
if (!state.ready) throw failure('The NRB network is not ready for payments.', 503);
|
||||
const owned = addresses(request.addresses);
|
||||
const target = integer(request.target);
|
||||
if (target <= 0n) throw failure('Invalid payment amount.');
|
||||
const outputs = await rpc('getaddressutxos', [{addresses:owned}]);
|
||||
if (!Array.isArray(outputs)) throw failure('Unexpected network output list.', 502);
|
||||
const candidates = outputs.filter(o => owned.includes(o.address) && Number(o.height) > 0 &&
|
||||
typeof o.script === 'string' && /^76a914[0-9a-f]{40}88ac$/.test(o.script) && integer(o.satoshis) > 0n)
|
||||
.sort((a, b) => integer(a.satoshis) > integer(b.satoshis) ? -1 : 1);
|
||||
const selected = []; let total = 0n; let inspected = 0;
|
||||
for (const output of candidates) {
|
||||
if (selected.length >= 100 || inspected++ >= 200) break;
|
||||
const unspent = await rpc('gettxout', [output.txid, Number(output.outputIndex), true]);
|
||||
if (!unspent || Number(unspent.confirmations) < 1 ||
|
||||
(unspent.coinbase && Number(unspent.confirmations) < 150)) continue;
|
||||
const value = integer(output.satoshis);
|
||||
if (coins(unspent.value) !== value || unspent.scriptPubKey.hex !== output.script) continue;
|
||||
const raw = await rpc('getrawtransaction', [output.txid, 0]);
|
||||
selected.push({txid:output.txid, vout:Number(output.outputIndex), satoshis:value.toString(), raw});
|
||||
total += value;
|
||||
if (total >= target) break;
|
||||
}
|
||||
if (total < target) throw failure('Insufficient spendable funds. Unconfirmed or immature outputs cannot be spent.');
|
||||
return {...state, inputs:selected};
|
||||
}
|
||||
async function handle(request) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) throw failure('Invalid request.');
|
||||
if (!['status', 'account', 'inputs', 'broadcast'].includes(request.action)) throw failure('Unknown operation.');
|
||||
// Reject any accidental secret-bearing field instead of forwarding arbitrary RPC.
|
||||
const fields = {status:['action'], account:['action','addresses'], inputs:['action','addresses','target'], broadcast:['action','hex']};
|
||||
if (Object.keys(request).some(k => !fields[request.action].includes(k))) throw failure('Unexpected request field.');
|
||||
const state = await chainStatus();
|
||||
if (request.action === 'status') return state;
|
||||
if (request.action === 'account') return account(request, state);
|
||||
if (request.action === 'inputs') return inputs(request, state);
|
||||
if (!state.ready) throw failure('The NRB network is not ready for payments.', 503);
|
||||
if (typeof request.hex !== 'string' || request.hex.length < 20 || request.hex.length > 400000 ||
|
||||
request.hex.length % 2 || !/^[a-f0-9]+$/i.test(request.hex)) throw failure('Invalid signed transaction.');
|
||||
try { return {...state, txid:await rpc('sendrawtransaction', [request.hex, false])}; }
|
||||
catch (e) {
|
||||
if (e.rpcCode === -26 || e.rpcCode === -25 || e.rpcCode === -22)
|
||||
throw failure('The network rejected the transaction. Check its status before sending again.');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const server = http.createServer(async (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (req.method !== 'POST' || req.url !== '/wallet/v1') {
|
||||
res.writeHead(404); res.end(JSON.stringify({error:'Not found'})); return;
|
||||
}
|
||||
if (active >= 8) { res.writeHead(429); res.end(JSON.stringify({error:'Please try again shortly.'})); return; }
|
||||
active++;
|
||||
try {
|
||||
const parts = []; let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > 512 * 1024) throw failure('Request is too large.', 413);
|
||||
parts.push(chunk);
|
||||
}
|
||||
let body;
|
||||
try { body = JSON.parse(Buffer.concat(parts).toString('utf8')); }
|
||||
catch (_) { throw failure('Invalid request JSON.'); }
|
||||
const result = await handle(body);
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (e) {
|
||||
res.writeHead(e.status || 500);
|
||||
res.end(JSON.stringify({genesis:GENESIS, error:e.status ? e.message : 'Wallet network service failed.'}));
|
||||
} finally { active--; }
|
||||
});
|
||||
server.requestTimeout = 30000;
|
||||
server.headersTimeout = 15000;
|
||||
server.listen(3002, '127.0.0.1');
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
#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();
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
#include "wallet_store.h"
|
||||
#include <sodium.h>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
constexpr int KeyBytes = crypto_aead_xchacha20poly1305_ietf_KEYBYTES;
|
||||
constexpr int MaxWalletBytes = 8 * 1024 * 1024;
|
||||
const QByteArray Aad = "Nerbus encrypted wallet v1|NRB|" + NrbGenesis.toLatin1();
|
||||
constexpr unsigned long long KdfOps = 3;
|
||||
constexpr size_t KdfMemory = 64 * 1024 * 1024;
|
||||
QByteArray readLimited(const QString &path) {
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly) || file.size() < 1 || file.size() > MaxWalletBytes)
|
||||
walletError("Cannot read wallet file.");
|
||||
return file.readAll();
|
||||
}
|
||||
QJsonObject walletHeader(const QByteArray &file) {
|
||||
QJsonParseError parse;
|
||||
const auto doc = QJsonDocument::fromJson(file, &parse);
|
||||
const auto header = doc.object();
|
||||
if (parse.error != QJsonParseError::NoError || !doc.isObject() ||
|
||||
header["format"] != "NerbusWallet" || header["genesis"].toString() != NrbGenesis ||
|
||||
(header["version"].toInt() != 1 &&
|
||||
!(header["version"].toInt() == 2 && header["protection"] == "none")))
|
||||
walletError("Unsupported wallet file.");
|
||||
return header;
|
||||
}
|
||||
QJsonObject walletContents(const QJsonObject &data) {
|
||||
if (data["genesis"].toString() != NrbGenesis || !data["keys"].isArray() ||
|
||||
data["keys"].toArray().size() > 1000)
|
||||
walletError("Invalid wallet contents.");
|
||||
return data;
|
||||
}
|
||||
void atomicWrite(const QString &path, const QByteArray &data) {
|
||||
QSaveFile file(path);
|
||||
file.setDirectWriteFallback(false);
|
||||
if (!file.open(QIODevice::WriteOnly) ||
|
||||
!file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner) ||
|
||||
file.write(data) != data.size() || !file.commit())
|
||||
walletError("Cannot save wallet file. Check the folder permissions and free space.");
|
||||
}
|
||||
unsigned char *newSecret() {
|
||||
auto *p = static_cast<unsigned char *>(sodium_malloc(KeyBytes));
|
||||
if (!p) walletError("Cannot allocate secure wallet memory.");
|
||||
return p;
|
||||
}
|
||||
void derive(QByteArray &password, const QByteArray &salt, unsigned char *key) {
|
||||
const int result = crypto_pwhash(key, KeyBytes, password.constData(), password.size(),
|
||||
reinterpret_cast<const unsigned char *>(salt.constData()), KdfOps, KdfMemory, crypto_pwhash_ALG_ARGON2ID13);
|
||||
wipe(password);
|
||||
if (result != 0) walletError("Not enough memory to unlock the wallet.");
|
||||
}
|
||||
}
|
||||
|
||||
void wipe(QByteArray &bytes) {
|
||||
if (!bytes.isEmpty()) sodium_memzero(bytes.data(), bytes.size());
|
||||
bytes.clear();
|
||||
}
|
||||
void walletError(const QString &message) { throw std::runtime_error(message.toUtf8().constData()); }
|
||||
|
||||
QJsonObject offlineCore(QJsonObject request) {
|
||||
QProcess process;
|
||||
QString executable = QCoreApplication::applicationDirPath() + "/nerbus-wallet-core";
|
||||
#ifdef Q_OS_WIN
|
||||
executable += ".exe";
|
||||
#endif
|
||||
if (!QFileInfo::exists(executable)) walletError("The wallet signing component is missing. Extract the complete wallet package.");
|
||||
process.setProgram(executable);
|
||||
process.setArguments({"-wallet-json"});
|
||||
process.setProcessChannelMode(QProcess::SeparateChannels);
|
||||
process.start();
|
||||
if (!process.waitForStarted(10000)) walletError("Cannot start the wallet signing component.");
|
||||
QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact);
|
||||
request = {};
|
||||
const qint64 expected = payload.size();
|
||||
const qint64 written = process.write(payload);
|
||||
wipe(payload);
|
||||
process.closeWriteChannel();
|
||||
if (written != expected || !process.waitForFinished(60000)) {
|
||||
process.kill(); process.waitForFinished(3000);
|
||||
walletError("The wallet signing component did not finish.");
|
||||
}
|
||||
QByteArray output = process.readAllStandardOutput();
|
||||
QByteArray errors = process.readAllStandardError();
|
||||
wipe(errors); // Never display process diagnostics that could contain private input.
|
||||
QJsonParseError parse;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(output, &parse);
|
||||
wipe(output);
|
||||
if (parse.error != QJsonParseError::NoError || !doc.isObject())
|
||||
walletError("The wallet signing component returned an invalid response.");
|
||||
QJsonObject result = doc.object();
|
||||
if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0 || result.contains("error"))
|
||||
walletError(result.value("error").toString("Wallet operation failed."));
|
||||
return result;
|
||||
}
|
||||
|
||||
WalletStore::WalletStore(QString path) : filename(std::move(path)) {}
|
||||
WalletStore::~WalletStore() { lock(); }
|
||||
bool WalletStore::exists() const { return QFileInfo::exists(filename); }
|
||||
bool WalletStore::fileRequiresPassword(const QString &path) {
|
||||
QByteArray file = readLimited(path);
|
||||
try {
|
||||
const bool protectedFile = walletHeader(file)["version"].toInt() == 1;
|
||||
wipe(file);
|
||||
return protectedFile;
|
||||
} catch (...) { wipe(file); throw; }
|
||||
}
|
||||
|
||||
QJsonObject WalletStore::decrypt(const QByteArray &file, QByteArray &password,
|
||||
unsigned char *outKey, QByteArray &outSalt) {
|
||||
QJsonObject header = walletHeader(file);
|
||||
if (header["version"].toInt() == 2) {
|
||||
wipe(password);
|
||||
outSalt.clear();
|
||||
return walletContents(header["data"].toObject());
|
||||
}
|
||||
if (!outKey || header["kdf"] != "argon2id13" ||
|
||||
header["ops"].toInt() != int(KdfOps) || header["memory"].toInt() != int(KdfMemory))
|
||||
walletError("Unsupported wallet file.");
|
||||
outSalt = QByteArray::fromBase64(header["salt"].toString().toLatin1());
|
||||
QByteArray nonce = QByteArray::fromBase64(header["nonce"].toString().toLatin1());
|
||||
QByteArray cipher = QByteArray::fromBase64(header["ciphertext"].toString().toLatin1());
|
||||
if (outSalt.size() != crypto_pwhash_SALTBYTES || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES)
|
||||
walletError("Incomplete wallet file.");
|
||||
derive(password, outSalt, outKey);
|
||||
QByteArray clear(cipher.size(), Qt::Uninitialized);
|
||||
unsigned long long length = 0;
|
||||
int rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
reinterpret_cast<unsigned char *>(clear.data()), &length, nullptr,
|
||||
reinterpret_cast<const unsigned char *>(cipher.constData()), cipher.size(),
|
||||
reinterpret_cast<const unsigned char *>(Aad.constData()), Aad.size(),
|
||||
reinterpret_cast<const unsigned char *>(nonce.constData()), outKey);
|
||||
if (rc != 0) { wipe(clear); walletError("Incorrect password or damaged wallet file."); }
|
||||
clear.resize(qsizetype(length));
|
||||
QJsonParseError parse;
|
||||
QJsonDocument payload = QJsonDocument::fromJson(clear, &parse);
|
||||
wipe(clear);
|
||||
auto data = payload.object();
|
||||
if (parse.error != QJsonParseError::NoError)
|
||||
walletError("Invalid wallet contents.");
|
||||
return walletContents(data);
|
||||
}
|
||||
|
||||
void WalletStore::save(const QJsonObject &data) {
|
||||
if (!unlocked()) walletError("Open the wallet first.");
|
||||
QByteArray clear = QJsonDocument(data).toJson(QJsonDocument::Compact);
|
||||
if (plainOpen) {
|
||||
const QJsonObject header{{"format", "NerbusWallet"}, {"version", 2},
|
||||
{"genesis", NrbGenesis}, {"protection", "none"}, {"data", data}};
|
||||
QByteArray file = QJsonDocument(header).toJson(QJsonDocument::Compact);
|
||||
try { atomicWrite(filename, file); }
|
||||
catch (...) { wipe(file); wipe(clear); throw; }
|
||||
wipe(file);
|
||||
wipe(plaintext);
|
||||
plaintext = std::move(clear);
|
||||
return;
|
||||
}
|
||||
QByteArray nonce(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, Qt::Uninitialized);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
QByteArray cipher(clear.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES, Qt::Uninitialized);
|
||||
unsigned long long length = 0;
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt(reinterpret_cast<unsigned char *>(cipher.data()), &length,
|
||||
reinterpret_cast<const unsigned char *>(clear.constData()), clear.size(),
|
||||
reinterpret_cast<const unsigned char *>(Aad.constData()), Aad.size(), nullptr,
|
||||
reinterpret_cast<const unsigned char *>(nonce.constData()), key);
|
||||
QJsonObject header{{"format", "NerbusWallet"}, {"version", 1}, {"genesis", NrbGenesis},
|
||||
{"kdf", "argon2id13"}, {"ops", int(KdfOps)}, {"memory", int(KdfMemory)},
|
||||
{"salt", QString::fromLatin1(salt.toBase64())}, {"nonce", QString::fromLatin1(nonce.toBase64())},
|
||||
{"ciphertext", QString::fromLatin1(cipher.toBase64())}};
|
||||
try { atomicWrite(filename, QJsonDocument(header).toJson(QJsonDocument::Compact)); }
|
||||
catch (...) { wipe(clear); throw; }
|
||||
wipe(plaintext);
|
||||
plaintext = std::move(clear);
|
||||
}
|
||||
|
||||
void WalletStore::create(QByteArray password) {
|
||||
if (exists()) { wipe(password); walletError("A wallet already exists here."); }
|
||||
if (!QDir().mkpath(QFileInfo(filename).absolutePath())) walletError("Cannot create the wallet folder.");
|
||||
lock();
|
||||
try {
|
||||
plainOpen = password.isEmpty();
|
||||
if (!plainOpen) {
|
||||
key = newSecret();
|
||||
salt.resize(crypto_pwhash_SALTBYTES);
|
||||
randombytes_buf(salt.data(), salt.size());
|
||||
derive(password, salt, key);
|
||||
}
|
||||
save({{"genesis", NrbGenesis}, {"keys", QJsonArray{}},
|
||||
{"created", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||||
} catch (...) { wipe(password); lock(); throw; }
|
||||
}
|
||||
|
||||
void WalletStore::unlock(QByteArray password) {
|
||||
lock();
|
||||
QByteArray file;
|
||||
try {
|
||||
file = readLimited(filename);
|
||||
plainOpen = walletHeader(file)["version"].toInt() == 2;
|
||||
if (!plainOpen) key = newSecret();
|
||||
QJsonObject data = decrypt(file, password, key, salt);
|
||||
plaintext = QJsonDocument(data).toJson(QJsonDocument::Compact);
|
||||
wipe(file);
|
||||
} catch (...) { wipe(file); wipe(password); lock(); throw; }
|
||||
}
|
||||
void WalletStore::changePassword(QByteArray password) {
|
||||
if (!unlocked()) { wipe(password); walletError("Open the wallet first."); }
|
||||
unsigned char *candidate = nullptr;
|
||||
QByteArray candidateSalt;
|
||||
try {
|
||||
if (!password.isEmpty()) {
|
||||
candidate = newSecret();
|
||||
candidateSalt.resize(crypto_pwhash_SALTBYTES);
|
||||
randombytes_buf(candidateSalt.data(), candidateSalt.size());
|
||||
derive(password, candidateSalt, candidate);
|
||||
}
|
||||
} catch (...) { wipe(password); if (candidate) sodium_free(candidate); throw; }
|
||||
auto *oldKey = key;
|
||||
const QByteArray oldSalt = salt;
|
||||
const bool wasPlain = plainOpen;
|
||||
key = candidate; salt = candidateSalt; plainOpen = candidate == nullptr;
|
||||
try { save(QJsonDocument::fromJson(plaintext).object()); }
|
||||
catch (...) {
|
||||
if (candidate) sodium_free(candidate);
|
||||
key = oldKey; salt = oldSalt; plainOpen = wasPlain;
|
||||
throw;
|
||||
}
|
||||
if (oldKey) sodium_free(oldKey);
|
||||
}
|
||||
void WalletStore::lock() {
|
||||
wipe(plaintext);
|
||||
if (key) { sodium_free(key); key = nullptr; }
|
||||
plainOpen = false;
|
||||
salt.clear();
|
||||
}
|
||||
QJsonArray WalletStore::entries() const {
|
||||
if (!unlocked()) walletError("Open the wallet first.");
|
||||
return QJsonDocument::fromJson(plaintext).object()["keys"].toArray();
|
||||
}
|
||||
QString WalletStore::addKey(const QJsonObject &pair, const QString &label) {
|
||||
if (!unlocked()) walletError("Open the wallet first.");
|
||||
QString address = pair["address"].toString();
|
||||
if (address.isEmpty() || pair["privateKey"].toString().isEmpty()) walletError("Invalid key response.");
|
||||
QJsonObject data = QJsonDocument::fromJson(plaintext).object();
|
||||
QJsonArray keys = data["keys"].toArray();
|
||||
for (const auto &entry : keys) if (entry.toObject()["address"] == address) return address;
|
||||
if (keys.size() >= 1000) walletError("This wallet already contains 1,000 addresses.");
|
||||
keys.append(QJsonObject{{"address", address}, {"privateKey", pair["privateKey"]},
|
||||
{"label", label.left(200)}, {"created", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||||
data["keys"] = keys;
|
||||
save(data); // Persist the private key before returning an address to the UI.
|
||||
return address;
|
||||
}
|
||||
void WalletStore::backup(const QString &destination) const {
|
||||
if (QFileInfo(destination).absoluteFilePath() == QFileInfo(filename).absoluteFilePath())
|
||||
walletError("Choose a different location for the backup.");
|
||||
QByteArray file = readLimited(filename);
|
||||
try { atomicWrite(destination, file); }
|
||||
catch (...) { wipe(file); throw; }
|
||||
wipe(file);
|
||||
}
|
||||
void WalletStore::restore(const QString &source, QByteArray password) {
|
||||
if (exists()) { wipe(password); walletError("Restore into a new wallet folder to preserve the current wallet."); }
|
||||
QByteArray file = readLimited(source);
|
||||
auto *candidate = newSecret();
|
||||
QByteArray candidateSalt;
|
||||
try {
|
||||
const auto data = decrypt(file, password, candidate, candidateSalt);
|
||||
if (!QDir().mkpath(QFileInfo(filename).absolutePath())) walletError("Cannot create the wallet folder.");
|
||||
atomicWrite(filename, file);
|
||||
lock();
|
||||
plainOpen = walletHeader(file)["version"].toInt() == 2;
|
||||
if (plainOpen) sodium_free(candidate);
|
||||
else { key = candidate; salt = candidateSalt; }
|
||||
candidate = nullptr;
|
||||
plaintext = QJsonDocument(data).toJson(QJsonDocument::Compact);
|
||||
} catch (...) { wipe(file); wipe(password); if (candidate) sodium_free(candidate); throw; }
|
||||
wipe(file);
|
||||
wipe(password);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <QtCore>
|
||||
#include <array>
|
||||
|
||||
inline const QString NrbGenesis = QStringLiteral("0177ee4010f9617feb8e2956aa4dfb7a8df18b6e4f6cc3359bdb8cfef9d10bfd");
|
||||
void wipe(QByteArray &bytes);
|
||||
[[noreturn]] void walletError(const QString &message);
|
||||
QJsonObject offlineCore(QJsonObject request);
|
||||
|
||||
class WalletStore {
|
||||
QString filename;
|
||||
unsigned char *key = nullptr;
|
||||
bool plainOpen = false;
|
||||
QByteArray salt;
|
||||
QByteArray plaintext;
|
||||
QJsonObject decrypt(const QByteArray &file, QByteArray &password, unsigned char *outKey, QByteArray &outSalt);
|
||||
void save(const QJsonObject &data);
|
||||
public:
|
||||
explicit WalletStore(QString path);
|
||||
~WalletStore();
|
||||
bool exists() const;
|
||||
bool unlocked() const { return key != nullptr || plainOpen; }
|
||||
bool passwordProtected() const { return key != nullptr; }
|
||||
static bool fileRequiresPassword(const QString &path);
|
||||
QString path() const { return filename; }
|
||||
void create(QByteArray password);
|
||||
void unlock(QByteArray password);
|
||||
void changePassword(QByteArray password);
|
||||
void lock();
|
||||
QJsonArray entries() const;
|
||||
QString addKey(const QJsonObject &pair, const QString &label);
|
||||
void backup(const QString &destination) const;
|
||||
void restore(const QString &source, QByteArray password);
|
||||
};
|
||||
Reference in new issue
Block a user