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