What changed, and why it matters
This commit fixes a wallet-creation bug: when restoring a Monero wallet from a special newer-style seed (called a Polyseed) using a JSON configuration file, the wallet software previously treated it like an older Electrum-style seed. This could cause the restore height to be set incorrectly and could apply an optional seed passphrase to the wrong key derivation path. The patch makes the JSON restore path recognize Polyseeds and handle their passphrase and birthday height correctly. It also preserves the restore height in the simplewallet command-line tool when no explicit restore height is provided.
Treat this as a bug-fix commit with low security impact. Users who restored Polyseed wallets via --generate-from-json before this patch should verify their wallet addresses and transaction history, and consider regenerating the wallet from the Polyseed on an updated client if a seed passphrase was used.
Security signals we found
Incorrect key derivation path for Polyseed when a seed passphrase is supplied via JSON
Restore height mishandling causing wallet to scan from wrong block height
Functional test added to assert correct Polyseed address, birthday, and encryption flag
Evidence from the diff
The change modifies wallet2::generate_from_json to detect Polyseed mnemonics via words_to_bytes_ex and route them through the Polyseed-aware wallet->generate() overload. It moves seed_passphrase parsing outside the Electrum-only branch so it is available for both paths, and only decrypts the recovery_key with the passphrase for non-Polyseed seeds. It marks refresh_from_block_height as explicit for Polyseeds so the Polyseed birthday is not overwritten. simplewallet::init now copies the wallet’s refresh_from_block_height into m_restore_height when no –restore-height argument is supplied, preventing an unintended reset to zero.
Changed components
src/wallet/wallet2.cppsrc/simplewallet/simplewallet.cppMonero wallet JSON restore pathPolyseed seed restorationInspect captured patch +40 / −11
### src/simplewallet/simplewallet.cpp
@@ -4346,6 +4346,8 @@ bool simple_wallet::init(const boost::program_options::variables_map& vm)
password = rc.second.password();
if (!m_wallet) return false;
m_wallet_file = m_wallet->path();
+ if (command_line::is_arg_defaulted(vm, arg_restore_height))
+ m_restore_height = m_wallet->get_refresh_from_block_height();
}
catch (const std::exception &e)
{
### src/wallet/wallet2.cpp
@@ -629,23 +629,22 @@ std::pair<std::unique_ptr<tools::wallet2>, tools::password_container> generate_f
}
GET_FIELD_FROM_JSON_RETURN_ON_ERROR(json, seed, std::string, String, false, std::string());
+ GET_FIELD_FROM_JSON_RETURN_ON_ERROR(json, seed_passphrase, std::string, String, false, std::string());
std::string old_language;
crypto::secret_key recovery_key;
bool restore_deterministic_wallet = false;
+ bool is_polyseed = false;
+ polyseed::data polyseed(POLYSEED_MONERO);
if (field_seed_found)
{
- if (!crypto::ElectrumWords::words_to_bytes(field_seed, recovery_key, old_language))
+ if (!crypto::ElectrumWords::words_to_bytes_ex(field_seed, recovery_key, old_language, is_polyseed, polyseed))
{
THROW_WALLET_EXCEPTION(tools::error::wallet_internal_error, tools::wallet2::tr("Electrum-style word list failed verification"));
}
restore_deterministic_wallet = true;
- GET_FIELD_FROM_JSON_RETURN_ON_ERROR(json, seed_passphrase, std::string, String, false, std::string());
- if (field_seed_passphrase_found)
- {
- if (!field_seed_passphrase.empty())
- recovery_key = cryptonote::decrypt_key(recovery_key, field_seed_passphrase);
- }
+ if (!is_polyseed && !field_seed_passphrase.empty())
+ recovery_key = cryptonote::decrypt_key(recovery_key, field_seed_passphrase);
}
GET_FIELD_FROM_JSON_RETURN_ON_ERROR(json, address, std::string, String, false, std::string());
@@ -694,20 +693,26 @@ std::pair<std::unique_ptr<tools::wallet2>, tools::password_container> generate_f
}
}
- const bool deprecated_wallet = restore_deterministic_wallet && ((old_language == crypto::ElectrumWords::old_language_name) ||
+ const bool deprecated_wallet = restore_deterministic_wallet && !is_polyseed && ((old_language == crypto::ElectrumWords::old_language_name) ||
crypto::ElectrumWords::get_is_old_style_seed(field_seed));
THROW_WALLET_EXCEPTION_IF(deprecated_wallet, tools::error::wallet_internal_error,
tools::wallet2::tr("Cannot generate deprecated wallets from JSON"));
wallet.reset(make_basic(vm, unattended, opts, password_prompter).release());
wallet->set_refresh_from_block_height(field_scan_from_height);
- wallet->explicit_refresh_from_block_height(field_scan_from_height_found);
+ wallet->explicit_refresh_from_block_height(field_scan_from_height_found || is_polyseed);
if (!old_language.empty())
wallet->set_seed_language(old_language);
try
{
- if (!field_seed.empty())
+ if (is_polyseed)
+ {
+ // scan_from_height 0 or absent: generate() uses the Polyseed birthday
+ wallet->generate(field_filename, field_password, polyseed, field_seed_passphrase, recover, field_scan_from_height, create_address_file);
+ password = field_password;
+ }
+ else if (!field_seed.empty())
{
wallet->generate(field_filename, field_password, recovery_key, recover, false, create_address_file);
password = field_password;
### tests/functional_tests/functional_tests_rpc.py
@@ -45,7 +45,8 @@
# 4 wallets connected to the main offline monerod
# 1 wallet connected to the first local online monerod
# 1 offline wallet
-N_WALLETS = 7
+# 1 offline wallet generated from a JSON file with a Polyseed
+N_WALLETS = 8
WALLET_DIRECTORY = builddir + "/functional-tests-directory"
FUNCTIONAL_TESTS_DIRECTORY = builddir + "/tests/functional_tests"
@@ -69,8 +70,13 @@
["--daemon-port", "18182", "--disable-rpc-login"],
["--offline", "--disable-rpc-login"],
["--daemon-port", "18184", "--daemon-login", "md5_lover:Z1ON0101", "--rpc-login", "kyle:reveille"],
+ ["--offline", "--disable-rpc-login", "--generate-from-json", WALLET_DIRECTORY + "/polyseed.json"],
]
+os.makedirs(WALLET_DIRECTORY, exist_ok = True)
+with open(WALLET_DIRECTORY + "/polyseed.json", "w") as f:
+ f.write('{"version": 1, "filename": "", "password": "", "seed": "pulse tone truth head invite orphan sock wet crumble oven price corn pilot antenna luxury strategy", "seed_passphrase": "abc"}')
+
command_lines = []
processes = []
outputs = []
@@ -87,6 +93,10 @@
command_lines.append([str(18090+i) if x == "wallet_port" else x for x in wallet_base])
if i < len(wallet_extra):
command_lines[-1] += wallet_extra[i]
+ if "--generate-from-json" in wallet_extra[i] and "--wallet-dir" in command_lines[-1]:
+ # monero-wallet-rpc ignores --generate-from-json when --wallet-dir is given
+ idx = command_lines[-1].index("--wallet-dir")
+ del command_lines[-1][idx:idx+2]
outputs.append(open(FUNCTIONAL_TESTS_DIRECTORY + '/wallet' + str(i) + '.log', 'a+'))
ports.append(18090+i)
### tests/functional_tests/wallet.py
@@ -52,6 +52,7 @@ def run_test(self):
self.wallet_exists()
self.languages()
self.generate_from_keys()
+ self.generate_from_json()
self.change_password()
self.store()
@@ -427,6 +428,17 @@ def generate_from_keys(self):
wallet.close_wallet()
util_resources.remove_wallet_files(filename)
+ def generate_from_json(self):
+ print('Testing wallet generated from JSON with a Polyseed and a seed passphrase')
+ wallet = Wallet(idx = 7)
+ res = wallet.get_address()
+ # 455jFA8H... without the passphrase, see create()
+ assert res.address == '49PemLZHxP1hCUsbcRZVrAJWBjn7dYi9UQGEqTVAo3hWY1a8PD14Mdcf2fNC5QN3iM6XahTc9qdMi2W3i75C2KU5B7ZDiqn'
+ res = wallet.query_key('mnemonic')
+ assert res.key == 'pulse tone truth head invite orphan sock wet crumble oven price corn pilot antenna luxury strategy'
+ assert res.polyseed_birthday == 1783033776
+ assert not res.polyseed_is_encrypted
+
def change_password(self):
print('Testing password change')
wallet = Wallet()Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.