derive public keys from the seed when importing a sparrow wallet file
What changed, and why it matters
This commit changes how Sparrow Wallet restores its own wallet files. Previously, when importing a Sparrow wallet file, the public keys (used to find transactions and addresses) might not be correctly rebuilt from the seed phrase. The fix forces the app to derive public keys from the seed during import, and adds tests to confirm imported wallets pass internal consistency checks. It is a correctness/reliability fix rather than an obvious remote attack vector.
Treat as a routine bug-fix commit. Review whether the missing public-key derivation could have caused users to see wrong addresses or balances after import, and consider whether a release note is warranted. No immediate incident response is indicated by the diff alone.
Security signals we found
Correctness fix for key material restoration during wallet import
Adds test coverage for encrypted and unencrypted seed-based wallet import
Adds test coverage for watch-only wallet import
Catches MnemonicException during import to prevent unhandled failures
Evidence from the diff
In Sparrow.java, after loading a wallet from a Sparrow-format file, the importer now calls storage.restorePublicKeysFromSeed(wallet, null) on the root wallet and every child wallet. This ensures that keystore xpubs and derived public keys are regenerated from the mnemonic seed rather than relying solely on persisted data. The catch block now also handles MnemonicException. New unit tests verify that encrypted, unencrypted, and watch-only Sparrow wallets import correctly and that imported.checkWallet() does not throw.
Changed components
src/main/java/com/sparrowwallet/sparrow/io/Sparrow.javasrc/test/java/com/sparrowwallet/sparrow/io/SparrowTest.javasrc/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.javaInspect captured patch +149 / −2
### src/main/java/com/sparrowwallet/sparrow/io/Sparrow.java
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.IOUtils;
+import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
@@ -117,8 +118,13 @@ public Wallet importWallet(InputStream inputStream, String password) throws Impo
}
}
+ storage.restorePublicKeysFromSeed(wallet, null);
+ for(Wallet childWallet : wallet.getChildWallets()) {
+ storage.restorePublicKeysFromSeed(childWallet, null);
+ }
+
return wallet;
- } catch(IOException | StorageException e) {
+ } catch(IOException | StorageException | MnemonicException e) {
throw new ImportException("Error importing Sparrow wallet", e);
} finally {
if(storage != null) {
### src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java
@@ -24,6 +24,7 @@
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.Comparator;
+import java.util.stream.Stream;
public class DbPersistenceTest {
private Path tempDir;
@@ -36,7 +37,9 @@ public void setUp() throws Exception {
@AfterEach
public void tearDown() throws Exception {
if(tempDir != null) {
- Files.walk(tempDir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+ try(Stream<Path> paths = Files.walk(tempDir)) {
+ paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+ }
}
}
### src/test/java/com/sparrowwallet/sparrow/io/SparrowTest.java
@@ -0,0 +1,138 @@
+package com.sparrowwallet.sparrow.io;
+
+import com.sparrowwallet.drongo.ExtendedKey;
+import com.sparrowwallet.drongo.KeyDerivation;
+import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
+import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.crypto.EncryptionType;
+import com.sparrowwallet.drongo.crypto.Key;
+import com.sparrowwallet.drongo.policy.Policy;
+import com.sparrowwallet.drongo.policy.PolicyType;
+import com.sparrowwallet.drongo.protocol.ScriptType;
+import com.sparrowwallet.drongo.wallet.DeterministicSeed;
+import com.sparrowwallet.drongo.wallet.Keystore;
+import com.sparrowwallet.drongo.wallet.KeystoreSource;
+import com.sparrowwallet.drongo.wallet.Wallet;
+import com.sparrowwallet.drongo.wallet.WalletModel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+public class SparrowTest {
+ private static final String TEST_MNEMONIC = "response seminar brave tip suit recall often sound stick owner lottery motion";
+ private static final String TEST_XPUB = "xpub6BrhGFTWPd3DXo8s2BPxHHzCmBCyj8QvamcEUaq8EDwnwXpvvcU9LzpJqENHcqHkqwTn2vPhynGVoEqj3PAB3NxnYZrvCsSfoCniJKaggdy";
+ private static final String DERIVATION = "m/84'/0'/0'";
+
+ private Path tempDir;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ tempDir = Files.createTempDirectory("sprw-sparrow-import");
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ if(tempDir != null) {
+ try(Stream<Path> paths = Files.walk(tempDir)) {
+ paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+ }
+ }
+ }
+
+ private Wallet createSeedWallet() throws Exception {
+ Wallet wallet = new Wallet("Seed Wallet");
+ wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setScriptType(ScriptType.P2WPKH);
+
+ DeterministicSeed seed = new DeterministicSeed(TEST_MNEMONIC, "", 0, DeterministicSeed.Type.BIP39);
+ Keystore keystore = Keystore.fromSeed(seed, PolicyType.SINGLE_HD, KeyDerivation.parsePath(DERIVATION));
+ keystore.setLabel("Keystore 1");
+ wallet.getKeystores().add(keystore);
+ wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, ScriptType.P2WPKH, wallet.getKeystores(), null));
+
+ return wallet;
+ }
+
+ private Wallet createWatchOnlyWallet() {
+ Wallet wallet = new Wallet("Watch Only Wallet");
+ wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setScriptType(ScriptType.P2WPKH);
+
+ Keystore keystore = new Keystore("Keystore 1");
+ keystore.setSource(KeystoreSource.SW_WATCH);
+ keystore.setWalletModel(WalletModel.SPARROW);
+ keystore.setKeyDerivation(new KeyDerivation("60bcd3a7", DERIVATION));
+ keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(TEST_XPUB));
+ wallet.getKeystores().add(keystore);
+ wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, ScriptType.P2WPKH, wallet.getKeystores(), null));
+
+ return wallet;
+ }
+
+ private File saveWallet(Wallet wallet, CharSequence password) throws Exception {
+ Storage storage = new Storage(PersistenceType.DB, tempDir.resolve(wallet.getName() + "." + PersistenceType.DB.getExtension()).toFile());
+ storage.setKeyDeriver(new Argon2KeyDeriver());
+
+ if(password == null) {
+ storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
+ } else {
+ ECKey encryptionFullKey = storage.getKeyDeriver().deriveECKey(password);
+ Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
+ wallet.encrypt(key);
+ storage.setEncryptionPubKey(ECKey.fromPublicOnly(encryptionFullKey));
+ }
+
+ try {
+ storage.saveWallet(wallet);
+ } finally {
+ storage.closeAndWait();
+ }
+
+ return storage.getWalletFile();
+ }
+
+ private Wallet importWallet(File walletFile, String password) throws Exception {
+ try(ByteArrayInputStream inputStream = new ByteArrayInputStream(Files.readAllBytes(walletFile.toPath()))) {
+ return new Sparrow().importWallet(inputStream, password);
+ }
+ }
+
+ @Test
+ public void encryptedSeedWalletImports() throws Exception {
+ Wallet wallet = createSeedWallet();
+ ExtendedKey expectedXpub = wallet.getKeystores().getFirst().getExtendedPublicKey();
+ File walletFile = saveWallet(wallet, "pass");
+
+ Wallet imported = importWallet(walletFile, "pass");
+ Assertions.assertEquals(expectedXpub, imported.getKeystores().getFirst().getExtendedPublicKey());
+ Assertions.assertDoesNotThrow(imported::checkWallet);
+ }
+
+ @Test
+ public void unencryptedSeedWalletImports() throws Exception {
+ Wallet wallet = createSeedWallet();
+ ExtendedKey expectedXpub = wallet.getKeystores().getFirst().getExtendedPublicKey();
+ File walletFile = saveWallet(wallet, null);
+
+ Wallet imported = importWallet(walletFile, null);
+ Assertions.assertEquals(expectedXpub, imported.getKeystores().getFirst().getExtendedPublicKey());
+ Assertions.assertDoesNotThrow(imported::checkWallet);
+ }
+
+ @Test
+ public void watchOnlyWalletImports() throws Exception {
+ File walletFile = saveWallet(createWatchOnlyWallet(), null);
+
+ Wallet imported = importWallet(walletFile, null);
+ Assertions.assertEquals(ExtendedKey.fromDescriptor(TEST_XPUB), imported.getKeystores().getFirst().getExtendedPublicKey());
+ Assertions.assertDoesNotThrow(imported::checkWallet);
+ }
+}Why this scored 41/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.