commit 73401987df1d354820d45b8d87457507d06fb300 Author: Nerbus Contributors Date: Sat Oct 17 18:56:27 2026 +0000 Publish Nerbus source snapshot and build documentation 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); +};