introduce database persistence with automatic migration of existing wallets

This commit is contained in:
Craig Raw
2021-06-10 12:08:35 +02:00
parent 600a77da3a
commit a59d5d3086
52 changed files with 1989 additions and 283 deletions
@@ -762,11 +762,10 @@ public class AppController implements Initializable {
public void openWalletFile(File file, boolean forceSameWindow) {
try {
Storage storage = new Storage(file);
FileType fileType = IOUtils.getFileType(file);
if(FileType.JSON.equals(fileType)) {
if(!storage.isEncrypted()) {
WalletBackupAndKey walletBackupAndKey = storage.loadUnencryptedWallet();
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
} else if(FileType.BINARY.equals(fileType)) {
} else {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> optionalPassword = dlg.showAndWait();
if(optionalPassword.isEmpty()) {
@@ -776,12 +775,12 @@ public class AppController implements Initializable {
SecureString password = optionalPassword.get();
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage, password);
loadWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Done"));
WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
});
loadWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Failed"));
Throwable exception = loadWalletService.getException();
if(exception instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
@@ -796,11 +795,11 @@ public class AppController implements Initializable {
password.clear();
}
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.START, "Decrypting wallet..."));
loadWalletService.start();
} else {
throw new IOException("Unsupported file type");
}
} catch(StorageException e) {
showErrorDialog("Error Opening Wallet", e.getMessage());
} catch(Exception e) {
if(!attemptImportWallet(file, null)) {
log.error("Error opening wallet", e);
@@ -822,6 +821,7 @@ public class AppController implements Initializable {
}
Platform.runLater(() -> selectTab(walletBackupAndKey.getWallet()));
} catch(Exception e) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage());
} finally {
walletBackupAndKey.clear();
@@ -969,13 +969,13 @@ public class AppController implements Initializable {
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
storage.saveWallet(wallet);
addWalletTabOrWindow(storage, wallet, null, false);
} catch(IOException e) {
} catch(IOException | StorageException e) {
log.error("Error saving imported wallet", e);
}
} else {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = null;
@@ -986,7 +986,7 @@ public class AppController implements Initializable {
storage.setEncryptionPubKey(encryptionPubKey);
storage.saveWallet(wallet);
addWalletTabOrWindow(storage, wallet, null, false);
} catch(IOException e) {
} catch(IOException | StorageException e) {
log.error("Error saving imported wallet", e);
} finally {
encryptionFullKey.clear();
@@ -996,10 +996,10 @@ public class AppController implements Initializable {
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Failed"));
showErrorDialog("Error encrypting wallet", keyDerivationService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.START, "Encrypting wallet..."));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.START, "Encrypting wallet..."));
keyDerivationService.start();
}
}
@@ -1080,12 +1080,12 @@ public class AppController implements Initializable {
walletTabData.getStorage().backupTempWallet();
wallet.clearHistory();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, walletTabData.getWalletForm().getWalletId()));
}
}
public AppController addWalletTabOrWindow(Storage storage, Wallet wallet, Wallet backupWallet, boolean forceSameWindow) {
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage);
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage.getWalletId(wallet));
if(existingWalletWindow instanceof Stage) {
Stage existingWalletStage = (Stage)existingWalletWindow;
existingWalletStage.toFront();
@@ -1109,10 +1109,7 @@ public class AppController implements Initializable {
public void addWalletTab(Storage storage, Wallet wallet, Wallet backupWallet) {
try {
String name = storage.getWalletFile().getName();
if(name.endsWith(".json")) {
name = name.substring(0, name.lastIndexOf('.'));
}
String name = storage.getWalletName(wallet);
if(!name.equals(wallet.getName())) {
wallet.setName(name);
}
@@ -1504,7 +1501,7 @@ public class AppController implements Initializable {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData) {
WalletTabData walletTabData = (WalletTabData)tabData;
if(walletTabData.getWalletForm().getWalletFile().equals(event.getWalletFile())) {
if(walletTabData.getWalletForm().getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid());
}
}
@@ -1819,6 +1816,17 @@ public class AppController implements Initializable {
@Subscribe
public void viewWallet(ViewWalletEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
if(event.getStorage().getWalletId(event.getWallet()).equals(walletTabData.getWalletForm().getWalletId())) {
tabs.getSelectionModel().select(tab);
return;
}
}
}
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
@@ -469,8 +469,8 @@ public class AppServices {
return openWallets;
}
public Window getWindowForWallet(Storage storage) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getStorage().getWalletFile().equals(storage.getWalletFile()))).map(Map.Entry::getKey).findFirst();
public Window getWindowForWallet(String walletId) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWalletForm().getWalletId().equals(walletId))).map(Map.Entry::getKey).findFirst();
return optWindow.orElse(null);
}
@@ -544,8 +544,7 @@ public class AppServices {
}
public static boolean isWalletFile(File file) {
FileType fileType = IOUtils.getFileType(file);
return FileType.JSON.equals(fileType) || FileType.BINARY.equals(fileType);
return Storage.isWalletFile(file);
}
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
@@ -616,10 +615,6 @@ public class AppServices {
}
}
public void moveToWalletWindowScreen(Storage storage, Dialog<?> dialog) {
moveToWindowScreen(getWindowForWallet(storage), dialog);
}
public static void moveToWindowScreen(Window currentWindow, Dialog<?> dialog) {
Window newWindow = dialog.getDialogPane().getScene().getWindow();
DialogPane dialogPane = dialog.getDialogPane();
@@ -7,8 +7,6 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.FileType;
import com.sparrowwallet.sparrow.io.IOUtils;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.Bwt;
import com.sparrowwallet.sparrow.net.PublicElectrumServer;
@@ -99,9 +97,8 @@ public class MainApp extends Application {
List<File> recentWalletFiles = Config.get().getRecentWalletFiles();
if(recentWalletFiles != null) {
//Re-sort to preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(file -> FileType.BINARY.equals(IOUtils.getFileType(file))).collect(Collectors.toList());
Collections.reverse(encryptedWalletFiles);
//Preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(Storage::isEncrypted).collect(Collectors.toList());
List<File> sortedWalletFiles = new ArrayList<>(recentWalletFiles);
sortedWalletFiles.removeAll(encryptedWalletFiles);
sortedWalletFiles.addAll(encryptedWalletFiles);
@@ -5,8 +5,8 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryClearedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
@@ -92,7 +92,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, storage.getWalletFile()));
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, storage.getWalletId(wallet)));
}
});
if(wallet.getBirthDate() == null) {
@@ -100,10 +100,10 @@ public class FileWalletExportPane extends TitledDescriptionPane {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
final File walletFile = AppServices.get().getOpenWallets().get(wallet).getWalletFile();
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
try {
@@ -113,10 +113,10 @@ public class FileWalletExportPane extends TitledDescriptionPane {
}
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
setError("Export Error", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
@@ -368,16 +368,16 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
signUnencryptedKeystore(decryptedWallet);
decryptedWallet.clearPrivate();
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
/**
* This event is trigger when one or more keystores on a wallet are updated, and the wallet is saved
*/
public class KeystoreLabelsChangedEvent extends WalletSettingsChangedEvent {
private final List<Keystore> changedKeystores;
public KeystoreLabelsChangedEvent(Wallet wallet, Wallet pastWallet, String walletId, List<Keystore> changedKeystores) {
super(wallet, pastWallet, walletId);
this.changedKeystores = changedKeystores;
}
public List<Keystore> getChangedKeystores() {
return changedKeystores;
}
}
@@ -8,9 +8,9 @@ import java.util.Map;
public class StorageEvent extends TimedEvent {
private static boolean firstRunDone = false;
private static final Map<File, Long> eventTime = new HashMap<>();
private static final Map<String, Long> eventTime = new HashMap<>();
public StorageEvent(File file, Action action, String status) {
public StorageEvent(String walletId, Action action, String status) {
super(action, status);
Integer keyDerivationPeriod = Config.get().getKeyDerivationPeriod();
@@ -19,10 +19,10 @@ public class StorageEvent extends TimedEvent {
}
if(action == Action.START) {
eventTime.put(file, System.currentTimeMillis());
eventTime.put(walletId, System.currentTimeMillis());
timeMills = keyDerivationPeriod;
} else if(action == Action.END) {
long start = eventTime.get(file);
long start = eventTime.get(walletId);
if(firstRunDone) {
keyDerivationPeriod = (int)(System.currentTimeMillis() - start);
Config.get().setKeyDerivationPeriod(keyDerivationPeriod);
@@ -11,8 +11,8 @@ import java.io.File;
* This is because any failure in saving the wallet must be immediately reported to the user.
* Note that all wallet detail controllers that share a WalletForm, and that class posts WalletNodesChangedEvent once it has cleared it's entry caches.
*/
public class WalletAddressesChangedEvent extends WalletSettingsChangedEvent {
public WalletAddressesChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
super(wallet, pastWallet, walletFile);
public class WalletAddressesChangedEvent extends WalletHistoryClearedEvent {
public WalletAddressesChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -7,9 +7,8 @@ import java.util.List;
/**
* This event is fired when a wallet entry (transaction, txi or txo) label is changed.
* Extends WalletDataChangedEvent so triggers a background save.
*/
public class WalletEntryLabelsChangedEvent extends WalletDataChangedEvent {
public class WalletEntryLabelsChangedEvent extends WalletChangedEvent {
private final List<Entry> entries;
public WalletEntryLabelsChangedEvent(Wallet wallet, Entry entry) {
@@ -23,8 +23,8 @@ public class WalletHistoryChangedEvent extends WalletChangedEvent {
this.historyChangedNodes = historyChangedNodes;
}
public File getWalletFile() {
return storage.getWalletFile();
public String getWalletId() {
return storage.getWalletId(getWallet());
}
public List<WalletNode> getHistoryChangedNodes() {
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryClearedEvent extends WalletSettingsChangedEvent {
public WalletHistoryClearedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletPasswordChangedEvent extends WalletSettingsChangedEvent {
public WalletPasswordChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -5,25 +5,24 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
/**
* This event is posted when a wallet's settings are changed in a way that does not update the wallet addresses or history.
* For example, changing the keystore source or label will trigger this event, which updates the wallet but avoids a full wallet refresh.
* It is impossible for the validity of a wallet to change on this event - listen for WalletAddressesChangedEvent for this
* This is the base class for events posted when a wallet's settings are changed
* Do not listen for this event directly - listen for a subclass, for example KeystoreLabelsChangedEvent or WalletAddressesChangedEvent
*/
public class WalletSettingsChangedEvent extends WalletChangedEvent {
private final Wallet pastWallet;
private final File walletFile;
private final String walletId;
public WalletSettingsChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
public WalletSettingsChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet);
this.pastWallet = pastWallet;
this.walletFile = walletFile;
this.walletId = walletId;
}
public Wallet getPastWallet() {
return pastWallet;
}
public File getWalletFile() {
return walletFile;
public String getWalletId() {
return walletId;
}
}
@@ -3,7 +3,7 @@ package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletUtxoStatusChangedEvent extends WalletDataChangedEvent {
public class WalletUtxoStatusChangedEvent extends WalletChangedEvent {
private final BlockTransactionHashIndex utxo;
public WalletUtxoStatusChangedEvent(Wallet wallet, BlockTransactionHashIndex utxo) {
@@ -17,6 +17,9 @@
package com.sparrowwallet.sparrow.instance;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataInputStream;
@@ -85,6 +88,7 @@ import java.nio.channels.FileLock;
*
*/
public abstract class Instance {
private static final Logger log = LoggerFactory.getLogger(Instance.class);
// starting position of port check
private static final int PORT_START = 7221;
@@ -513,7 +517,7 @@ public abstract class Instance {
* @param exception exception occurring while first instance is listening for subsequent instances
*/
protected void handleException(Exception exception) {
exception.printStackTrace();
log.error("Error listening for instances", exception);
}
/**
@@ -16,10 +16,12 @@ public class IOUtils {
if(file.exists()) {
try(BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line = br.readLine();
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
if(line != null) {
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
}
}
}
}
@@ -18,6 +18,7 @@ import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;
@@ -34,58 +35,77 @@ public class JsonPersistence implements Persistence {
this.gson = getGson();
}
public Wallet loadWallet(File jsonFile) throws IOException {
try(Reader reader = new FileReader(jsonFile)) {
return gson.fromJson(reader, Wallet.class);
@Override
public WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException {
Wallet wallet;
try(Reader reader = new FileReader(storage.getWalletFile())) {
wallet = gson.fromJson(reader, Wallet.class);
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, null);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
File backupFile = storage.getTempBackup();
Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, null);
return new WalletBackupAndKey(wallet, backupWallet, null, null, childWallets);
}
public WalletBackupAndKey loadWallet(File walletFile, CharSequence password) throws IOException, StorageException {
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException {
return loadWallet(storage, password, null);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
Wallet wallet;
ECKey encryptionKey;
try(InputStream fileStream = new FileInputStream(walletFile)) {
encryptionKey = getEncryptionKey(password, fileStream, null);
try(InputStream fileStream = new FileInputStream(storage.getWalletFile())) {
encryptionKey = getEncryptionKey(password, fileStream, alreadyDerivedKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
wallet = gson.fromJson(reader, Wallet.class);
}
return new WalletBackupAndKey(wallet, null, encryptionKey, keyDeriver, null);
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, encryptionKey);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
File backupFile = storage.getTempBackup();
Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, encryptionKey);
return new WalletBackupAndKey(wallet, backupWallet, encryptionKey, keyDeriver, childWallets);
}
public Map<File, Wallet> loadWallets(File[] walletFiles, ECKey encryptionKey) throws IOException, StorageException {
Map<File, Wallet> walletsMap = new LinkedHashMap<>();
for(File file : walletFiles) {
if(encryptionKey != null) {
try(InputStream fileStream = new FileInputStream(file)) {
encryptionKey = getEncryptionKey(null, fileStream, encryptionKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
walletsMap.put(file, gson.fromJson(reader, Wallet.class));
}
} else {
walletsMap.put(file, loadWallet(file));
}
}
return walletsMap;
}
public Map<Storage, WalletBackupAndKey> loadChildWallets(File walletFile, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException {
File[] walletFiles = getChildWalletFiles(walletFile, masterWallet);
private Map<Storage, WalletBackupAndKey> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException {
File[] walletFiles = getChildWalletFiles(storage.getWalletFile(), masterWallet);
Map<Storage, WalletBackupAndKey> childWallets = new LinkedHashMap<>();
Map<File, Wallet> loadedWallets = loadWallets(walletFiles, encryptionKey);
for(Map.Entry<File, Wallet> entry : loadedWallets.entrySet()) {
Storage storage = new Storage(entry.getKey());
storage.setEncryptionPubKey(encryptionKey == null ? Storage.NO_PASSWORD_KEY : ECKey.fromPublicOnly(encryptionKey));
storage.setKeyDeriver(getKeyDeriver());
Wallet childWallet = entry.getValue();
for(File childFile : walletFiles) {
Wallet childWallet = loadWallet(childFile, encryptionKey);
Storage childStorage = new Storage(childFile);
childStorage.setEncryptionPubKey(encryptionKey == null ? Storage.NO_PASSWORD_KEY : ECKey.fromPublicOnly(encryptionKey));
childStorage.setKeyDeriver(getKeyDeriver());
childWallet.setMasterWallet(masterWallet);
childWallets.put(storage, new WalletBackupAndKey(childWallet, null, encryptionKey, keyDeriver, Collections.emptyMap()));
childWallets.put(childStorage, new WalletBackupAndKey(childWallet, null, encryptionKey, keyDeriver, Collections.emptyMap()));
}
return childWallets;
}
private Wallet loadWallet(File walletFile, ECKey encryptionKey) throws IOException, StorageException {
if(encryptionKey != null) {
try(InputStream fileStream = new FileInputStream(walletFile)) {
encryptionKey = getEncryptionKey(null, fileStream, encryptionKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
return gson.fromJson(reader, Wallet.class);
}
} else {
try(Reader reader = new FileReader(walletFile)) {
return gson.fromJson(reader, Wallet.class);
}
}
}
private File[] getChildWalletFiles(File walletFile, Wallet masterWallet) {
File childDir = new File(walletFile.getParentFile(), masterWallet.getName() + "-child");
if(!childDir.exists()) {
@@ -100,14 +120,12 @@ public class JsonPersistence implements Persistence {
return childFiles == null ? new File[0] : childFiles;
}
public File storeWallet(File walletFile, Wallet wallet) throws IOException {
File parent = walletFile.getParentFile();
if(!parent.exists() && !Storage.createOwnerOnlyDirectory(parent)) {
throw new IOException("Could not create folder " + parent);
}
@Override
public File storeWallet(Storage storage, Wallet wallet) throws IOException {
File walletFile = storage.getWalletFile();
if(!walletFile.getName().endsWith(".json")) {
File jsonFile = new File(parent, walletFile.getName() + ".json");
File jsonFile = new File(walletFile.getParentFile(), walletFile.getName() + ".json");
if(walletFile.exists()) {
if(!walletFile.renameTo(jsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + jsonFile.getName());
@@ -127,14 +145,12 @@ public class JsonPersistence implements Persistence {
return walletFile;
}
public File storeWallet(File walletFile, Wallet wallet, ECKey encryptionPubKey) throws IOException {
File parent = walletFile.getParentFile();
if(!parent.exists() && !Storage.createOwnerOnlyDirectory(parent)) {
throw new IOException("Could not create folder " + parent);
}
@Override
public File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException {
File walletFile = storage.getWalletFile();
if(walletFile.getName().endsWith(".json")) {
File noJsonFile = new File(parent, walletFile.getName().substring(0, walletFile.getName().lastIndexOf('.')));
File noJsonFile = new File(walletFile.getParentFile(), walletFile.getName().substring(0, walletFile.getName().lastIndexOf('.')));
if(walletFile.exists()) {
if(!walletFile.renameTo(noJsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + noJsonFile.getName());
@@ -158,6 +174,16 @@ public class JsonPersistence implements Persistence {
return walletFile;
}
@Override
public void updateWallet(Storage storage, Wallet wallet) throws IOException {
storeWallet(storage, wallet);
}
@Override
public void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException {
storeWallet(storage, wallet, encryptionPubKey);
}
private void writeBinaryHeader(OutputStream outputStream) throws IOException {
ByteBuffer buf = ByteBuffer.allocate(21);
buf.put(HEADER_MAGIC_1.getBytes(StandardCharsets.UTF_8));
@@ -174,6 +200,7 @@ public class JsonPersistence implements Persistence {
return "BIE1".getBytes(StandardCharsets.UTF_8);
}
@Override
public ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException {
return getEncryptionKey(password, null, null);
}
@@ -187,10 +214,12 @@ public class JsonPersistence implements Persistence {
return alreadyDerivedKey == null ? keyDeriver.deriveECKey(password) : alreadyDerivedKey;
}
@Override
public AsymmetricKeyDeriver getKeyDeriver() {
return keyDeriver;
}
@Override
public void setKeyDeriver(AsymmetricKeyDeriver keyDeriver) {
this.keyDeriver = keyDeriver;
}
@@ -232,10 +261,53 @@ public class JsonPersistence implements Persistence {
return new Argon2KeyDeriver(salt);
}
@Override
public PersistenceType getType() {
return PersistenceType.JSON;
}
@Override
public boolean isEncrypted(File walletFile) throws IOException {
FileType fileType = IOUtils.getFileType(walletFile);
if(FileType.JSON.equals(fileType)) {
return false;
} else if(FileType.BINARY.equals(fileType)) {
try(FileInputStream fileInputStream = new FileInputStream(walletFile)) {
getWalletKeyDeriver(fileInputStream);
return true;
} catch(StorageException e) {
return false;
}
}
throw new IOException("Unsupported file type");
}
@Override
public String getWalletId(Storage storage, Wallet wallet) {
return storage.getWalletFile().getParentFile().getAbsolutePath() + File.separator + getWalletName(storage.getWalletFile(), null) + ":" + (wallet == null || wallet.isMasterWallet() ? "master" : wallet.getName());
}
@Override
public String getWalletName(File walletFile, Wallet wallet) {
String name = walletFile.getName();
if(name.endsWith("." + getType().getExtension())) {
name = name.substring(0, name.lastIndexOf('.'));
}
return name;
}
@Override
public void copyWallet(File walletFile, OutputStream outputStream) throws IOException {
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public void close() {
//Nothing required
}
public static Gson getGson() {
return getGson(true);
}
@@ -261,7 +333,7 @@ public class JsonPersistence implements Persistence {
gsonBuilder.addSerializationExclusionStrategy(new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes field) {
return field.getDeclaringClass() == Wallet.class && field.getName().equals("masterWallet");
return field.getName().equals("id") || field.getName().equals("masterWallet") || field.getName().equals("childWallets");
}
@Override
@@ -6,17 +6,23 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.io.OutputStream;
public interface Persistence {
Wallet loadWallet(File walletFile) throws IOException;
WalletBackupAndKey loadWallet(File walletFile, CharSequence password) throws IOException, StorageException;
Map<File, Wallet> loadWallets(File[] walletFiles, ECKey encryptionKey) throws IOException, StorageException;
Map<Storage, WalletBackupAndKey> loadChildWallets(File walletFile, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException;
File storeWallet(File walletFile, Wallet wallet) throws IOException;
File storeWallet(File walletFile, Wallet wallet, ECKey encryptionPubKey) throws IOException;
WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException;
WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException;
WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException;
File storeWallet(Storage storage, Wallet wallet) throws IOException, StorageException;
File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException;
void updateWallet(Storage storage, Wallet wallet) throws IOException, StorageException;
void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException;
ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException;
AsymmetricKeyDeriver getKeyDeriver();
void setKeyDeriver(AsymmetricKeyDeriver keyDeriver);
PersistenceType getType();
boolean isEncrypted(File walletFile) throws IOException;
String getWalletId(Storage storage, Wallet wallet);
String getWalletName(File walletFile, Wallet wallet);
void copyWallet(File walletFile, OutputStream outputStream) throws IOException;
void close();
}
@@ -1,7 +1,30 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.sparrow.io.db.DbPersistence;
public enum PersistenceType {
JSON("json");
JSON("json") {
@Override
public String getExtension() {
return getName();
}
@Override
public Persistence getInstance() {
return new JsonPersistence();
}
},
DB("db") {
@Override
public String getExtension() {
return "mv.db";
}
@Override
public Persistence getInstance() {
return new DbPersistence();
}
};
private final String name;
@@ -13,7 +36,7 @@ public enum PersistenceType {
return name;
}
public String getExtension() {
return getName();
}
public abstract String getExtension();
public abstract Persistence getInstance();
}
@@ -26,7 +26,7 @@ public class Sparrow implements WalletExport {
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Files.copy(storage.getWalletFile().toPath(), outputStream);
storage.copyWallet(outputStream);
outputStream.flush();
} catch(Exception e) {
log.error("Error exporting Sparrow wallet file", e);
@@ -42,11 +42,7 @@ public class Sparrow implements WalletExport {
@Override
public String getExportFileExtension(Wallet wallet) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
if(storage != null && (storage.getEncryptionPubKey() == null || Storage.NO_PASSWORD_KEY.equals(storage.getEncryptionPubKey()))) {
return "json";
}
return "";
return storage.getWalletFileExtension();
}
@Override
@@ -36,14 +36,23 @@ public class Storage {
public static final String WALLETS_DIR = "wallets";
public static final String WALLETS_BACKUP_DIR = "backup";
public static final String CERTS_DIR = "certs";
public static final String TEMP_BACKUP_EXTENSION = "tmp";
public static final String TEMP_BACKUP_PREFIX = "tmp";
private final Persistence persistence;
private Persistence persistence;
private File walletFile;
private ECKey encryptionPubKey;
public Storage(File walletFile) {
this.persistence = new JsonPersistence();
this(!walletFile.exists() || walletFile.getName().endsWith("." + PersistenceType.DB.getExtension()) ? PersistenceType.DB : PersistenceType.JSON, walletFile);
}
public Storage(PersistenceType persistenceType, File walletFile) {
this.persistence = persistenceType.getInstance();
this.walletFile = walletFile;
}
public Storage(Persistence persistence, File walletFile) {
this.persistence = persistence;
this.walletFile = walletFile;
}
@@ -51,45 +60,63 @@ public class Storage {
return walletFile;
}
public WalletBackupAndKey loadUnencryptedWallet() throws IOException, StorageException {
Wallet wallet = persistence.loadWallet(walletFile);
Wallet backupWallet = loadBackupWallet(null);
Map<Storage, WalletBackupAndKey> childWallets = persistence.loadChildWallets(walletFile, wallet, null);
public boolean isEncrypted() throws IOException {
return persistence.isEncrypted(walletFile);
}
public String getWalletId(Wallet wallet) {
return persistence.getWalletId(this, wallet);
}
public String getWalletName(Wallet wallet) {
return persistence.getWalletName(walletFile, wallet);
}
public String getWalletFileExtension() {
if(walletFile.getName().endsWith("." + getType().getExtension())) {
return getType().getExtension();
}
return "";
}
public WalletBackupAndKey loadUnencryptedWallet() throws IOException, StorageException {
WalletBackupAndKey masterWalletAndKey = persistence.loadWallet(this);
encryptionPubKey = NO_PASSWORD_KEY;
return new WalletBackupAndKey(wallet, backupWallet, null, null, childWallets);
return migrateToDb(masterWalletAndKey);
}
public WalletBackupAndKey loadEncryptedWallet(CharSequence password) throws IOException, StorageException {
WalletBackupAndKey masterWalletAndKey = persistence.loadWallet(walletFile, password);
Wallet backupWallet = loadBackupWallet(masterWalletAndKey.getEncryptionKey());
Map<Storage, WalletBackupAndKey> childWallets = persistence.loadChildWallets(walletFile, masterWalletAndKey.getWallet(), masterWalletAndKey.getEncryptionKey());
WalletBackupAndKey masterWalletAndKey = persistence.loadWallet(this, password);
encryptionPubKey = ECKey.fromPublicOnly(masterWalletAndKey.getEncryptionKey());
return new WalletBackupAndKey(masterWalletAndKey.getWallet(), backupWallet, masterWalletAndKey.getEncryptionKey(), persistence.getKeyDeriver(), childWallets);
return migrateToDb(masterWalletAndKey);
}
protected Wallet loadBackupWallet(ECKey encryptionKey) throws IOException, StorageException {
Map<File, Wallet> backupWallets;
if(encryptionKey != null) {
File[] backups = getBackups(TEMP_BACKUP_EXTENSION, persistence.getType().getExtension() + "." + TEMP_BACKUP_EXTENSION);
backupWallets = persistence.loadWallets(backups, encryptionKey);
return backupWallets.isEmpty() ? null : backupWallets.values().iterator().next();
} else {
File[] backups = getBackups(persistence.getType().getExtension() + "." + TEMP_BACKUP_EXTENSION);
backupWallets = persistence.loadWallets(backups, null);
public void saveWallet(Wallet wallet) throws IOException, StorageException {
File parent = walletFile.getParentFile();
if(!parent.exists() && !Storage.createOwnerOnlyDirectory(parent)) {
throw new IOException("Could not create folder " + parent);
}
return backupWallets.isEmpty() ? null : backupWallets.values().iterator().next();
}
public void saveWallet(Wallet wallet) throws IOException {
if(encryptionPubKey != null && !NO_PASSWORD_KEY.equals(encryptionPubKey)) {
walletFile = persistence.storeWallet(walletFile, wallet, encryptionPubKey);
walletFile = persistence.storeWallet(this, wallet, encryptionPubKey);
return;
}
walletFile = persistence.storeWallet(walletFile, wallet);
walletFile = persistence.storeWallet(this, wallet);
}
public void updateWallet(Wallet wallet) throws IOException, StorageException {
if(encryptionPubKey != null && !NO_PASSWORD_KEY.equals(encryptionPubKey)) {
persistence.updateWallet(this, wallet, encryptionPubKey);
} else {
persistence.updateWallet(this, wallet);
}
}
public void close() {
ClosePersistenceService closePersistenceService = new ClosePersistenceService();
closePersistenceService.start();
}
public void backupWallet() throws IOException {
@@ -100,34 +127,36 @@ public class Storage {
public void backupTempWallet() {
try {
backupWallet(TEMP_BACKUP_EXTENSION);
backupWallet(TEMP_BACKUP_PREFIX);
} catch(IOException e) {
log.error("Error creating ." + TEMP_BACKUP_EXTENSION + " backup wallet", e);
log.error("Error creating " + TEMP_BACKUP_PREFIX + " backup wallet", e);
}
}
private void backupWallet(String extension) throws IOException {
private void backupWallet(String prefix) throws IOException {
File backupDir = getWalletsBackupDir();
Date backupDate = new Date();
String backupName = walletFile.getName();
String walletName = persistence.getWalletName(walletFile, null);
String dateSuffix = "-" + BACKUP_DATE_FORMAT.format(backupDate);
int lastDot = backupName.lastIndexOf('.');
if(lastDot > 0) {
backupName = backupName.substring(0, lastDot) + dateSuffix + backupName.substring(lastDot);
} else {
backupName += dateSuffix;
}
String backupName = walletName + dateSuffix + walletFile.getName().substring(walletName.length());
if(extension != null) {
backupName += "." + extension;
if(prefix != null) {
backupName = prefix + "_" + backupName;
}
File backupFile = new File(backupDir, backupName);
if(!backupFile.exists()) {
createOwnerOnlyFile(backupFile);
}
com.google.common.io.Files.copy(walletFile, backupFile);
try(FileOutputStream outputStream = new FileOutputStream(backupFile)) {
copyWallet(outputStream);
}
}
public void copyWallet(OutputStream outputStream) throws IOException {
persistence.copyWallet(walletFile, outputStream);
}
public void deleteBackups() {
@@ -135,27 +164,29 @@ public class Storage {
}
public void deleteTempBackups() {
deleteBackups(Storage.TEMP_BACKUP_EXTENSION);
deleteBackups(Storage.TEMP_BACKUP_PREFIX);
}
private void deleteBackups(String extension) {
File[] backups = getBackups(extension);
private void deleteBackups(String prefix) {
File[] backups = getBackups(prefix);
for(File backup : backups) {
backup.delete();
}
}
private File[] getBackups(String extension) {
return getBackups(extension, null);
public File getTempBackup() {
File[] backups = getBackups(TEMP_BACKUP_PREFIX);
return backups.length == 0 ? null : backups[0];
}
private File[] getBackups(String extension, String notExtension) {
File[] getBackups(String prefix) {
File backupDir = getWalletsBackupDir();
String walletName = persistence.getWalletName(walletFile, null);
String extension = walletFile.getName().substring(walletName.length());
File[] backups = backupDir.listFiles((dir, name) -> {
return name.startsWith(com.google.common.io.Files.getNameWithoutExtension(walletFile.getName()) + "-") &&
return name.startsWith((prefix == null ? "" : prefix + "_") + walletName + "-") &&
getBackupDate(name) != null &&
(extension == null || name.endsWith("." + extension)) &&
(notExtension == null || !name.endsWith("." + notExtension));
(extension.isEmpty() || name.endsWith(extension));
});
backups = backups == null ? new File[0] : backups;
@@ -173,6 +204,49 @@ public class Storage {
return null;
}
private WalletBackupAndKey migrateToDb(WalletBackupAndKey masterWalletAndKey) throws IOException, StorageException {
if(getType() == PersistenceType.JSON) {
log.info("Migrating " + masterWalletAndKey.getWallet().getName() + " from JSON to DB persistence");
masterWalletAndKey = migrateType(PersistenceType.DB, masterWalletAndKey.getWallet(), masterWalletAndKey.getEncryptionKey());
}
return masterWalletAndKey;
}
private WalletBackupAndKey migrateType(PersistenceType type, Wallet wallet, ECKey encryptionKey) throws IOException, StorageException {
File existingFile = walletFile;
try {
AsymmetricKeyDeriver keyDeriver = persistence.getKeyDeriver();
persistence = type.getInstance();
persistence.setKeyDeriver(keyDeriver);
walletFile = new File(walletFile.getParentFile(), wallet.getName() + "." + type.getExtension());
if(walletFile.exists()) {
walletFile.delete();
}
saveWallet(wallet);
if(type == PersistenceType.DB) {
for(Wallet childWallet : wallet.getChildWallets()) {
saveWallet(childWallet);
}
}
if(NO_PASSWORD_KEY.equals(encryptionPubKey)) {
return persistence.loadWallet(this);
}
return persistence.loadWallet(this, null, encryptionKey);
} catch(Exception e) {
existingFile = null;
throw e;
} finally {
if(existingFile != null) {
existingFile.delete();
}
}
}
public ECKey getEncryptionPubKey() {
return encryptionPubKey;
}
@@ -193,6 +267,10 @@ public class Storage {
persistence.setKeyDeriver(keyDeriver);
}
public PersistenceType getType() {
return persistence.getType();
}
public static boolean walletExists(String walletName) {
File encrypted = new File(getWalletsDir(), walletName.trim());
if(encrypted.exists()) {
@@ -230,6 +308,38 @@ public class Storage {
return new File(getWalletsDir(), walletName);
}
public static boolean isWalletFile(File walletFile) {
for(PersistenceType type : PersistenceType.values()) {
if(walletFile.getName().endsWith("." + type.getExtension())) {
return true;
}
try {
if(type == PersistenceType.JSON && type.getInstance().isEncrypted(walletFile)) {
return true;
}
} catch(IOException e) {
//ignore
}
}
return false;
}
public static boolean isEncrypted(File walletFile) {
try {
for(PersistenceType type : PersistenceType.values()) {
if(walletFile.getName().endsWith("." + type.getExtension())) {
return type.getInstance().isEncrypted(walletFile);
}
}
} catch(IOException e) {
//ignore
}
return FileType.BINARY.equals(IOUtils.getFileType(walletFile));
}
public static File getWalletsBackupDir() {
File walletsBackupDir = new File(getWalletsDir(), WALLETS_BACKUP_DIR);
if(!walletsBackupDir.exists()) {
@@ -432,4 +542,16 @@ public class Storage {
};
}
}
public class ClosePersistenceService extends Service<Void> {
@Override
protected Task<Void> createTask() {
return new Task<>() {
protected Void call() {
persistence.close();
return null;
}
};
}
}
}
@@ -0,0 +1,60 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.Date;
import java.util.Map;
public interface BlockTransactionDao {
@SqlQuery("select id, txid, hash, height, date, fee, label, transaction, blockHash from blockTransaction where wallet = ? order by id")
@RegisterRowMapper(BlockTransactionMapper.class)
Map<Sha256Hash, BlockTransaction> getForWalletId(Long id);
@SqlQuery("select id, txid, hash, height, date, fee, label, transaction, blockHash from blockTransaction where txid = ?")
@RegisterRowMapper(BlockTransactionMapper.class)
Map<Sha256Hash, BlockTransaction> getForTxId(byte[] id);
@SqlUpdate("insert into blockTransaction (txid, hash, height, date, fee, label, transaction, blockHash, wallet) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertBlockTransaction(byte[] txid, byte[] hash, int height, Date date, Long fee, String label, byte[] transaction, byte[] blockHash, long wallet);
@SqlUpdate("update blockTransaction set txid = ?, hash = ?, height = ?, date = ?, fee = ?, label = ?, transaction = ?, blockHash = ?, wallet = ? where id = ?")
void updateBlockTransaction(byte[] txid, byte[] hash, int height, Date date, Long fee, String label, byte[] transaction, byte[] blockHash, long wallet, long id);
@SqlUpdate("update blockTransaction set label = :label where id = :id")
void updateLabel(@Bind("id") long id, @Bind("label") String label);
@SqlUpdate("delete from blockTransaction where wallet = ?")
void clear(long wallet);
default void addBlockTransactions(Wallet wallet) {
for(Map.Entry<Sha256Hash, BlockTransaction> blkTxEntry : wallet.getTransactions().entrySet()) {
blkTxEntry.getValue().setId(null);
addOrUpdate(wallet, blkTxEntry.getKey(), blkTxEntry.getValue());
}
}
default void addOrUpdate(Wallet wallet, Sha256Hash txid, BlockTransaction blkTx) {
Map<Sha256Hash, BlockTransaction> existing = getForTxId(txid.getBytes());
if(existing.isEmpty() && blkTx.getId() == null) {
long id = insertBlockTransaction(txid.getBytes(), blkTx.getHash().getBytes(), blkTx.getHeight(), blkTx.getDate(), blkTx.getFee(), blkTx.getLabel(),
blkTx.getTransaction() == null ? null : blkTx.getTransaction().bitcoinSerialize(),
blkTx.getBlockHash() == null ? null : blkTx.getBlockHash().getBytes(), wallet.getId());
blkTx.setId(id);
} else {
Long existingId = existing.get(txid) != null ? existing.get(txid).getId() : blkTx.getId();
updateBlockTransaction(txid.getBytes(), blkTx.getHash().getBytes(), blkTx.getHeight(), blkTx.getDate(), blkTx.getFee(), blkTx.getLabel(),
blkTx.getTransaction() == null ? null : blkTx.getTransaction().bitcoinSerialize(),
blkTx.getBlockHash() == null ? null : blkTx.getBlockHash().getBytes(), wallet.getId(), existingId);
blkTx.setId(existingId);
}
}
}
@@ -0,0 +1,26 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Status;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class BlockTransactionHashIndexMapper implements RowMapper<BlockTransactionHashIndex> {
@Override
public BlockTransactionHashIndex map(ResultSet rs, StatementContext ctx) throws SQLException {
BlockTransactionHashIndex blockTransactionHashIndex = new BlockTransactionHashIndex(Sha256Hash.wrap(rs.getBytes("blockTransactionHashIndex.hash")),
rs.getInt("blockTransactionHashIndex.height"), rs.getTimestamp("blockTransactionHashIndex.date"), rs.getLong("blockTransactionHashIndex.fee"),
rs.getLong("blockTransactionHashIndex.index"), rs.getLong("blockTransactionHashIndex.value"), null, rs.getString("blockTransactionHashIndex.label"));
blockTransactionHashIndex.setId(rs.getLong("blockTransactionHashIndex.id"));
int statusIndex = rs.getInt("blockTransactionHashIndex.status");
if(!rs.wasNull()) {
blockTransactionHashIndex.setStatus(Status.values()[statusIndex]);
}
return blockTransactionHashIndex;
}
}
@@ -0,0 +1,51 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
public class BlockTransactionMapper implements RowMapper<Map.Entry<Sha256Hash, BlockTransaction>> {
@Override
public Map.Entry<Sha256Hash, BlockTransaction> map(ResultSet rs, StatementContext ctx) throws SQLException {
Sha256Hash txid = Sha256Hash.wrap(rs.getBytes("txid"));
byte[] txBytes = rs.getBytes("transaction");
Transaction transaction = null;
if(txBytes != null) {
transaction = new Transaction(txBytes);
}
Long fee = rs.getLong("fee");
if(rs.wasNull()) {
fee = null;
}
BlockTransaction blockTransaction = new BlockTransaction(Sha256Hash.wrap(rs.getBytes("hash")), rs.getInt("height"), rs.getTimestamp("date"),
fee, transaction, rs.getBytes("blockHash") == null ? null : Sha256Hash.wrap(rs.getBytes("blockHash")), rs.getString("label"));
blockTransaction.setId(rs.getLong("id"));
return new Map.Entry<>() {
@Override
public Sha256Hash getKey() {
return txid;
}
@Override
public BlockTransaction getValue() {
return blockTransaction;
}
@Override
public BlockTransaction setValue(BlockTransaction value) {
return null;
}
};
}
}
@@ -0,0 +1,607 @@
package com.sparrowwallet.sparrow.io.db;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.wallet.*;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.pool.HikariPool;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.exception.FlywayValidateException;
import org.h2.tools.ChangeFileEncryption;
import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.core.h2.H2DatabasePlugin;
import org.jdbi.v3.sqlobject.SqlObjectPlugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class DbPersistence implements Persistence {
private static final Logger log = LoggerFactory.getLogger(DbPersistence.class);
static final String DEFAULT_SCHEMA = "PUBLIC";
private static final String WALLET_SCHEMA_PREFIX = "wallet_";
private static final String MASTER_SCHEMA = WALLET_SCHEMA_PREFIX + "master";
private static final byte[] H2_ENCRYPT_HEADER = "H2encrypt\n".getBytes(StandardCharsets.UTF_8);
private static final int H2_ENCRYPT_SALT_LENGTH_BYTES = 8;
private static final int SALT_LENGTH_BYTES = 16;
public static final byte[] HEADER_MAGIC_1 = "SPRW1\n".getBytes(StandardCharsets.UTF_8);
private static final String H2_USER = "sa";
private static final String H2_PASSWORD = "";
private HikariDataSource dataSource;
private AsymmetricKeyDeriver keyDeriver;
private Wallet masterWallet;
private final Map<Wallet, DirtyPersistables> dirtyPersistablesMap = new HashMap<>();
public DbPersistence() {
EventManager.get().register(this);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException {
return loadWallet(storage, null, null);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException {
return loadWallet(storage, password, null);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
ECKey encryptionKey = getEncryptionKey(password, storage.getWalletFile(), alreadyDerivedKey);
migrate(storage, MASTER_SCHEMA, encryptionKey);
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
masterWallet = jdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
return walletDao.getMainWallet(MASTER_SCHEMA);
});
File backupFile = storage.getTempBackup();
Wallet backupWallet = null;
if(backupFile != null) {
Persistence backupPersistence = PersistenceType.DB.getInstance();
backupWallet = backupPersistence.loadWallet(new Storage(backupPersistence, backupFile), password, encryptionKey).getWallet();
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, masterWallet, backupWallet, encryptionKey);
masterWallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
return new WalletBackupAndKey(masterWallet, backupWallet, encryptionKey, keyDeriver, childWallets);
}
private Map<Storage, WalletBackupAndKey> loadChildWallets(Storage storage, Wallet masterWallet, Wallet backupWallet, ECKey encryptionKey) throws StorageException {
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
List<String> schemas = jdbi.withHandle(handle -> {
return handle.createQuery("show schemas").mapTo(String.class).list();
});
List<String> childSchemas = schemas.stream().filter(schema -> schema.startsWith(WALLET_SCHEMA_PREFIX) && !schema.equals(MASTER_SCHEMA)).collect(Collectors.toList());
Map<Storage, WalletBackupAndKey> childWallets = new LinkedHashMap<>();
for(String schema : childSchemas) {
migrate(storage, schema, encryptionKey);
Jdbi childJdbi = getJdbi(storage, getFilePassword(encryptionKey));
Wallet wallet = childJdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
Wallet childWallet = walletDao.getMainWallet(schema);
childWallet.setName(schema.substring(WALLET_SCHEMA_PREFIX.length()));
childWallet.setMasterWallet(masterWallet);
return childWallet;
});
Wallet backupChildWallet = backupWallet == null ? null : backupWallet.getChildWallets().stream().filter(child -> wallet.getName().equals(child.getName())).findFirst().orElse(null);
childWallets.put(storage, new WalletBackupAndKey(wallet, backupChildWallet, encryptionKey, keyDeriver, Collections.emptyMap()));
}
return childWallets;
}
@Override
public File storeWallet(Storage storage, Wallet wallet) throws IOException, StorageException {
File walletFile = storage.getWalletFile();
walletFile = renameToDbFile(walletFile);
if(walletFile.exists() && isEncrypted(walletFile)) {
if(dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
walletFile.delete();
}
updatePassword(storage, null);
cleanAndAddWallet(storage, wallet, null);
return walletFile;
}
@Override
public File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException {
File walletFile = storage.getWalletFile();
walletFile = renameToDbFile(walletFile);
if(walletFile.exists() && !isEncrypted(walletFile)) {
if(dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
walletFile.delete();
}
boolean existing = walletFile.exists();
updatePassword(storage, encryptionPubKey);
cleanAndAddWallet(storage, wallet, getFilePassword(encryptionPubKey));
if(!existing) {
writeBinaryHeader(walletFile);
}
return walletFile;
}
@Override
public void updateWallet(Storage storage, Wallet wallet) throws StorageException {
updateWallet(storage, wallet, null);
}
@Override
public void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws StorageException {
updatePassword(storage, encryptionPubKey);
update(storage, wallet, getFilePassword(encryptionPubKey));
}
private File renameToDbFile(File walletFile) throws IOException {
if(!walletFile.getName().endsWith("." + getType().getExtension())) {
File dbFile = new File(walletFile.getParentFile(), walletFile.getName() + "." + getType().getExtension());
if(walletFile.exists()) {
if(!walletFile.renameTo(dbFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + dbFile.getName());
}
}
return dbFile;
}
return walletFile;
}
private void update(Storage storage, Wallet wallet, String password) throws StorageException {
DirtyPersistables dirtyPersistables = dirtyPersistablesMap.get(wallet);
if(dirtyPersistables == null) {
return;
}
log.debug("Updating " + wallet.getName() + " on " + Thread.currentThread().getName());
log.debug(dirtyPersistables.toString());
Jdbi jdbi = getJdbi(storage, password);
jdbi.useHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
try {
walletDao.setSchema(getSchema(wallet));
if(dirtyPersistables.clearHistory) {
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
walletNodeDao.clearHistory(wallet);
blockTransactionDao.clear(wallet.getId());
}
if(!dirtyPersistables.historyNodes.isEmpty()) {
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
Set<Sha256Hash> referencedTxIds = new HashSet<>();
for(WalletNode addressNode : dirtyPersistables.historyNodes) {
if(addressNode.getId() == null) {
WalletNode purposeNode = wallet.getNode(addressNode.getKeyPurpose());
if(purposeNode.getId() == null) {
long purposeNodeId = walletNodeDao.insertWalletNode(purposeNode.getDerivationPath(), purposeNode.getLabel(), wallet.getId(), null);
purposeNode.setId(purposeNodeId);
}
long nodeId = walletNodeDao.insertWalletNode(addressNode.getDerivationPath(), addressNode.getLabel(), wallet.getId(), purposeNode.getId());
addressNode.setId(nodeId);
}
Set<BlockTransactionHashIndex> txos = addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo)).collect(Collectors.toSet());
List<Long> existingIds = txos.stream().map(Persistable::getId).filter(Objects::nonNull).collect(Collectors.toList());
referencedTxIds.addAll(txos.stream().map(BlockTransactionHash::getHash).collect(Collectors.toSet()));
walletNodeDao.deleteNodeTxosNotInList(addressNode, existingIds.isEmpty() ? List.of(-1L) : existingIds);
for(BlockTransactionHashIndex txo : addressNode.getTransactionOutputs()) {
walletNodeDao.addOrUpdate(addressNode, txo);
}
}
for(Sha256Hash txid : referencedTxIds) {
BlockTransaction blkTx = wallet.getTransactions().get(txid);
blockTransactionDao.addOrUpdate(wallet, txid, blkTx);
}
}
if(dirtyPersistables.blockHeight != null) {
walletDao.updateStoredBlockHeight(wallet.getId(), dirtyPersistables.blockHeight);
}
if(!dirtyPersistables.labelEntries.isEmpty()) {
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
for(Entry entry : dirtyPersistables.labelEntries) {
if(entry instanceof TransactionEntry && ((TransactionEntry)entry).getBlockTransaction().getId() != null) {
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), entry.getLabel());
} else if(entry instanceof NodeEntry && ((NodeEntry)entry).getNode().getId() != null) {
walletNodeDao.updateNodeLabel(((NodeEntry)entry).getNode().getId(), entry.getLabel());
} else if(entry instanceof HashIndexEntry && ((HashIndexEntry)entry).getHashIndex().getId() != null) {
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), entry.getLabel());
}
}
}
if(!dirtyPersistables.utxoStatuses.isEmpty()) {
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
for(BlockTransactionHashIndex utxo : dirtyPersistables.utxoStatuses) {
walletNodeDao.updateTxoStatus(utxo.getId(), utxo.getStatus() == null ? null : utxo.getStatus().ordinal());
}
}
if(!dirtyPersistables.labelKeystores.isEmpty()) {
KeystoreDao keystoreDao = handle.attach(KeystoreDao.class);
for(Keystore keystore : dirtyPersistables.labelKeystores) {
keystoreDao.updateLabel(keystore.getLabel(), keystore.getId());
}
}
dirtyPersistablesMap.remove(wallet);
} finally {
walletDao.setSchema(DEFAULT_SCHEMA);
}
});
}
private void cleanAndAddWallet(Storage storage, Wallet wallet, String password) throws StorageException {
String schema = getSchema(wallet);
cleanAndMigrate(storage, schema, password);
Jdbi jdbi = getJdbi(storage, password);
jdbi.useHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
walletDao.addWallet(schema, wallet);
});
if(wallet.isMasterWallet()) {
masterWallet = wallet;
}
}
private void migrate(Storage storage, String schema, ECKey encryptionKey) throws StorageException {
try {
Flyway flyway = getFlyway(storage, schema, getFilePassword(encryptionKey));
flyway.migrate();
} catch(FlywayValidateException e) {
log.error("Failed to open wallet file. Validation error during schema migration.", e);
throw new StorageException("Failed to open wallet file. Validation error during schema migration.", e);
} catch(FlywayException e) {
log.error("Failed to open wallet file. ", e);
throw new StorageException("Failed to open wallet file.\n" + e.getMessage(), e);
}
}
private void cleanAndMigrate(Storage storage, String schema, String password) throws StorageException {
try {
Flyway flyway = getFlyway(storage, schema, password);
flyway.clean();
flyway.migrate();
} catch(FlywayException e) {
log.error("Failed to save wallet file.", e);
throw new StorageException("Failed to save wallet file.\n" + e.getMessage(), e);
}
}
private String getSchema(Wallet wallet) {
return wallet.isMasterWallet() ? MASTER_SCHEMA : WALLET_SCHEMA_PREFIX + wallet.getName();
}
private String getFilePassword(ECKey encryptionKey) {
if(encryptionKey == null) {
return null;
}
return Utils.bytesToHex(encryptionKey.getPubKey());
}
private void writeBinaryHeader(File walletFile) throws IOException {
ByteBuffer header = ByteBuffer.allocate(HEADER_MAGIC_1.length + SALT_LENGTH_BYTES);
header.put(HEADER_MAGIC_1);
header.put(keyDeriver.getSalt());
header.flip();
try(FileChannel fileChannel = new RandomAccessFile(walletFile, "rwd").getChannel()) {
fileChannel.position(H2_ENCRYPT_HEADER.length + H2_ENCRYPT_SALT_LENGTH_BYTES);
fileChannel.write(header);
}
}
private void updatePassword(Storage storage, ECKey encryptionPubKey) {
String newPassword = getFilePassword(encryptionPubKey);
String currentPassword = getDatasourcePassword();
//The password only needs to be changed if the datasource is null - either we have loaded the wallet from a datasource, or it is a new wallet and the datasource is still to be created
if(dataSource != null && !Objects.equals(currentPassword, newPassword)) {
if(!dataSource.isClosed()) {
dataSource.close();
}
try {
File walletFile = storage.getWalletFile();
ChangeFileEncryption.execute(walletFile.getParent(), getWalletName(walletFile, null), "AES",
currentPassword == null ? null : currentPassword.toCharArray(),
newPassword == null ? null : newPassword.toCharArray(), true);
if(newPassword != null) {
writeBinaryHeader(walletFile);
}
//This sets the new password on the datasource for the next updatePassword check
getDataSource(storage, newPassword);
} catch(Exception e) {
log.error("Error changing database password", e);
}
}
}
private String getDatasourcePassword() {
if(dataSource != null) {
String dsPassword = dataSource.getPassword();
if(dsPassword.isEmpty()) {
return null;
}
return dsPassword.substring(0, dsPassword.length() - (" " + H2_PASSWORD).length());
}
return null;
}
@Override
public ECKey getEncryptionKey(CharSequence password) throws IOException {
return getEncryptionKey(password, null, null);
}
private ECKey getEncryptionKey(CharSequence password, File walletFile, ECKey alreadyDerivedKey) throws IOException {
if(password != null && password.equals("")) {
return Storage.NO_PASSWORD_KEY;
}
AsymmetricKeyDeriver keyDeriver = getKeyDeriver(walletFile);
if(alreadyDerivedKey != null) {
return alreadyDerivedKey;
}
return password == null ? null : keyDeriver.deriveECKey(password);
}
@Override
public AsymmetricKeyDeriver getKeyDeriver() {
return keyDeriver;
}
@Override
public void setKeyDeriver(AsymmetricKeyDeriver keyDeriver) {
this.keyDeriver = keyDeriver;
}
private AsymmetricKeyDeriver getKeyDeriver(File walletFile) throws IOException {
if(keyDeriver == null) {
keyDeriver = getWalletKeyDeriver(walletFile);
}
return keyDeriver;
}
private AsymmetricKeyDeriver getWalletKeyDeriver(File walletFile) throws IOException {
if(keyDeriver == null) {
byte[] salt = new byte[SALT_LENGTH_BYTES];
if(walletFile != null && walletFile.exists()) {
try(InputStream inputStream = new FileInputStream(walletFile)) {
inputStream.skip(H2_ENCRYPT_HEADER.length + H2_ENCRYPT_SALT_LENGTH_BYTES + HEADER_MAGIC_1.length);
inputStream.read(salt, 0, salt.length);
}
} else {
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(salt);
}
return new Argon2KeyDeriver(salt);
}
return keyDeriver;
}
@Override
public boolean isEncrypted(File walletFile) throws IOException {
byte[] header = new byte[H2_ENCRYPT_HEADER.length];
try(InputStream inputStream = new FileInputStream(walletFile)) {
inputStream.read(header, 0, H2_ENCRYPT_HEADER.length);
return Arrays.equals(H2_ENCRYPT_HEADER, header);
}
}
@Override
public String getWalletId(Storage storage, Wallet wallet) {
return storage.getWalletFile().getParentFile().getAbsolutePath() + File.separator + getWalletName(storage.getWalletFile(), null) + ":" + (wallet == null || wallet.isMasterWallet() ? "master" : wallet.getName());
}
@Override
public String getWalletName(File walletFile, Wallet wallet) {
if(wallet != null && wallet.getMasterWallet() != null) {
return wallet.getName();
}
String name = walletFile.getName();
if(name.endsWith("." + getType().getExtension())) {
name = name.substring(0, name.length() - getType().getExtension().length() - 1);
}
return name;
}
@Override
public PersistenceType getType() {
return PersistenceType.DB;
}
@Override
public void copyWallet(File walletFile, OutputStream outputStream) throws IOException {
if(dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public void close() {
EventManager.get().unregister(this);
if(dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
private Jdbi getJdbi(Storage storage, String password) throws StorageException {
Jdbi jdbi = Jdbi.create(getDataSource(storage, password));
jdbi.installPlugin(new H2DatabasePlugin());
jdbi.installPlugin(new SqlObjectPlugin());
return jdbi;
}
private Flyway getFlyway(Storage storage, String schema, String password) throws StorageException {
return Flyway.configure().dataSource(getDataSource(storage, password)).locations("com/sparrowwallet/sparrow/sql").schemas(schema).load();
}
private HikariDataSource getDataSource(Storage storage, String password) throws StorageException {
if(dataSource == null || dataSource.isClosed()) {
dataSource = createDataSource(storage.getWalletFile(), password);
}
return dataSource;
}
private HikariDataSource createDataSource(File walletFile, String password) throws StorageException {
try {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(getUrl(walletFile, password));
config.setUsername(H2_USER);
config.setPassword(password == null ? H2_PASSWORD : password + " " + H2_PASSWORD);
return new HikariDataSource(config);
} catch(HikariPool.PoolInitializationException e) {
if(e.getMessage() != null && e.getMessage().contains("Database may be already in use")) {
log.error("Wallet file may already be in use. Make sure the application is not running elsewhere.", e);
throw new StorageException("Wallet file may already be in use. Make sure the application is not running elsewhere.", e);
} else if(e.getMessage() != null && (e.getMessage().contains("Wrong user name or password") || e.getMessage().contains("Encryption error in file"))) {
throw new InvalidPasswordException("Incorrect password for wallet file.", e);
} else {
log.error("Failed to open database file", e);
throw new StorageException("Failed to open database file.\n" + e.getMessage(), e);
}
}
}
private String getUrl(File walletFile, String password) {
return "jdbc:h2:" + walletFile.getAbsolutePath().replace("." + getType().getExtension(), "") + ";INIT=SET TRACE_LEVEL_FILE=4;TRACE_LEVEL_FILE=4;DATABASE_TO_UPPER=false" + (password == null ? "" : ";CIPHER=AES");
}
private boolean persistsFor(Wallet wallet) {
if(masterWallet != null) {
if(wallet == masterWallet) {
return true;
}
return masterWallet.getChildWallets().contains(wallet);
}
return false;
}
@Subscribe
public void walletHistoryCleared(WalletHistoryClearedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).clearHistory = true;
}
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).historyNodes.addAll(event.getHistoryChangedNodes());
}
}
@Subscribe
public void walletBlockHeightChanged(WalletBlockHeightChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).blockHeight = event.getBlockHeight();
}
}
@Subscribe
public void walletEntryLabelsChanged(WalletEntryLabelsChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelEntries.addAll(event.getEntries());
}
}
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).utxoStatuses.add(event.getUtxo());
}
}
@Subscribe
public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelKeystores.addAll(event.getChangedKeystores());
}
}
private static class DirtyPersistables {
public boolean clearHistory;
public final List<WalletNode> historyNodes = new ArrayList<>();
public Integer blockHeight = null;
public final List<Entry> labelEntries = new ArrayList<>();
public final List<BlockTransactionHashIndex> utxoStatuses = new ArrayList<>();
public final List<Keystore> labelKeystores = new ArrayList<>();
public String toString() {
return "Dirty Persistables" +
"\nClear history:" + clearHistory +
"\nNodes:" + historyNodes +
"\nBlockHeight:" + blockHeight +
"\nTx labels:" + labelEntries.stream().filter(entry -> entry instanceof TransactionEntry).map(entry -> ((TransactionEntry)entry).getBlockTransaction().getHash().toString()).collect(Collectors.toList()) +
"\nAddress labels:" + labelEntries.stream().filter(entry -> entry instanceof NodeEntry).map(entry -> ((NodeEntry)entry).getNode().toString() + " " + entry.getLabel()).collect(Collectors.toList()) +
"\nUTXO labels:" + labelEntries.stream().filter(entry -> entry instanceof HashIndexEntry).map(entry -> ((HashIndexEntry)entry).getHashIndex().toString()).collect(Collectors.toList()) +
"\nUTXO statuses:" + utxoStatuses +
"\nKeystore labels:" + labelKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList());
}
}
}
@@ -0,0 +1,74 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.crypto.EncryptedData;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.List;
public interface KeystoreDao {
@SqlQuery("select keystore.id, keystore.label, keystore.source, keystore.walletModel, keystore.masterFingerprint, keystore.derivationPath, keystore.extendedPublicKey, " +
"masterPrivateExtendedKey.id, masterPrivateExtendedKey.privateKey, masterPrivateExtendedKey.chainCode, masterPrivateExtendedKey.initialisationVector, masterPrivateExtendedKey.encryptedBytes, masterPrivateExtendedKey.keySalt, masterPrivateExtendedKey.deriver, masterPrivateExtendedKey.crypter, " +
"seed.id, seed.type, seed.mnemonicString, seed.initialisationVector, seed.encryptedBytes, seed.keySalt, seed.deriver, seed.crypter, seed.needsPassphrase, seed.creationTimeSeconds " +
"from keystore left join masterPrivateExtendedKey on keystore.masterPrivateExtendedKey = masterPrivateExtendedKey.id left join seed on keystore.seed = seed.id where keystore.wallet = ? order by keystore.index asc")
@RegisterRowMapper(KeystoreMapper.class)
List<Keystore> getForWalletId(Long id);
@SqlUpdate("insert into keystore (label, source, walletModel, masterFingerprint, derivationPath, extendedPublicKey, masterPrivateExtendedKey, seed, wallet, index) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insert(String label, int source, int walletModel, String masterFingerprint, String derivationPath, String extendedPublicKey, Long masterPrivateExtendedKey, Long seed, long wallet, int index);
@SqlUpdate("insert into masterPrivateExtendedKey (privateKey, chainCode, initialisationVector, encryptedBytes, keySalt, deriver, crypter, creationTimeSeconds) values (?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertMasterPrivateExtendedKey(byte[] privateKey, byte[] chainCode, byte[] initialisationVector, byte[] encryptedBytes, byte[] keySalt, Integer deriver, Integer crypter, long creationTimeSeconds);
@SqlUpdate("insert into seed (type, mnemonicString, initialisationVector, encryptedBytes, keySalt, deriver, crypter, needsPassphrase, creationTimeSeconds) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertSeed(int type, String mnemonicString, byte[] initialisationVector, byte[] encryptedBytes, byte[] keySalt, Integer deriver, Integer crypter, boolean needsPassphrase, long creationTimeSeconds);
@SqlUpdate("update keystore set label = ? where id = ?")
void updateLabel(String label, long id);
default void addKeystores(Wallet wallet) {
for(int i = 0; i < wallet.getKeystores().size(); i++) {
Keystore keystore = wallet.getKeystores().get(i);
if(keystore.getMasterPrivateExtendedKey() != null) {
MasterPrivateExtendedKey mpek = keystore.getMasterPrivateExtendedKey();
if(mpek.isEncrypted()) {
EncryptedData data = mpek.getEncryptedData();
long id = insertMasterPrivateExtendedKey(null, null, data.getInitialisationVector(), data.getEncryptedBytes(), data.getKeySalt(), data.getEncryptionType().getDeriver().ordinal(), data.getEncryptionType().getCrypter().ordinal(), mpek.getCreationTimeSeconds());
mpek.setId(id);
} else {
long id = insertMasterPrivateExtendedKey(mpek.getPrivateKey().getPrivKeyBytes(), mpek.getPrivateKey().getChainCode(), null, null, null, null, null, mpek.getCreationTimeSeconds());
mpek.setId(id);
}
}
if(keystore.getSeed() != null) {
DeterministicSeed seed = keystore.getSeed();
if(seed.isEncrypted()) {
EncryptedData data = seed.getEncryptedData();
long id = insertSeed(seed.getType().ordinal(), null, data.getInitialisationVector(), data.getEncryptedBytes(), data.getKeySalt(), data.getEncryptionType().getDeriver().ordinal(), data.getEncryptionType().getCrypter().ordinal(), seed.needsPassphrase(), seed.getCreationTimeSeconds());
seed.setId(id);
} else {
long id = insertSeed(seed.getType().ordinal(), seed.getMnemonicString().asString(), null, null, null, null, null, seed.needsPassphrase(), seed.getCreationTimeSeconds());
seed.setId(id);
}
}
long id = insert(keystore.getLabel(), keystore.getSource().ordinal(), keystore.getWalletModel().ordinal(),
keystore.hasPrivateKey() ? null : keystore.getKeyDerivation().getMasterFingerprint(),
keystore.getKeyDerivation().getDerivationPath(),
keystore.hasPrivateKey() ? null : keystore.getExtendedPublicKey().toString(),
keystore.getMasterPrivateExtendedKey() == null ? null : keystore.getMasterPrivateExtendedKey().getId(),
keystore.getSeed() == null ? null : keystore.getSeed().getId(), wallet.getId(), i);
keystore.setId(id);
}
}
}
@@ -0,0 +1,58 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.EncryptedData;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.wallet.*;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;
public class KeystoreMapper implements RowMapper<Keystore> {
@Override
public Keystore map(ResultSet rs, StatementContext ctx) throws SQLException {
Keystore keystore = new Keystore(rs.getString("keystore.label"));
keystore.setId(rs.getLong("keystore.id"));
keystore.setSource(KeystoreSource.values()[rs.getInt("keystore.source")]);
keystore.setWalletModel(WalletModel.values()[rs.getInt("keystore.walletModel")]);
keystore.setKeyDerivation(new KeyDerivation(rs.getString("keystore.masterFingerprint"), rs.getString("keystore.derivationPath")));
keystore.setExtendedPublicKey(rs.getString("keystore.extendedPublicKey") == null ? null : ExtendedKey.fromDescriptor(rs.getString("keystore.extendedPublicKey")));
if(rs.getBytes("masterPrivateExtendedKey.privateKey") != null) {
MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(rs.getBytes("masterPrivateExtendedKey.privateKey"), rs.getBytes("masterPrivateExtendedKey.chainCode"));
masterPrivateExtendedKey.setId(rs.getLong("masterPrivateExtendedKey.id"));
keystore.setMasterPrivateExtendedKey(masterPrivateExtendedKey);
} else if(rs.getBytes("masterPrivateExtendedKey.encryptedBytes") != null) {
EncryptedData encryptedData = new EncryptedData(rs.getBytes("masterPrivateExtendedKey.initialisationVector"),
rs.getBytes("masterPrivateExtendedKey.encryptedBytes"), rs.getBytes("masterPrivateExtendedKey.keySalt"),
EncryptionType.Deriver.values()[rs.getInt("masterPrivateExtendedKey.deriver")],
EncryptionType.Crypter.values()[rs.getInt("masterPrivateExtendedKey.crypter")]);
MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(encryptedData);
masterPrivateExtendedKey.setId(rs.getLong("masterPrivateExtendedKey.id"));
keystore.setMasterPrivateExtendedKey(masterPrivateExtendedKey);
}
if(rs.getString("seed.mnemonicString") != null) {
List<String> mnemonicCode = Arrays.asList(rs.getString("seed.mnemonicString").split(" "));
DeterministicSeed seed = new DeterministicSeed(mnemonicCode, rs.getBoolean("seed.needsPassphrase"), rs.getLong("seed.creationTimeSeconds"), DeterministicSeed.Type.values()[rs.getInt("seed.type")]);
seed.setId(rs.getLong("seed.id"));
keystore.setSeed(seed);
} else if(rs.getBytes("seed.encryptedBytes") != null) {
EncryptedData encryptedData = new EncryptedData(rs.getBytes("seed.initialisationVector"),
rs.getBytes("seed.encryptedBytes"), rs.getBytes("seed.keySalt"),
EncryptionType.Deriver.values()[rs.getInt("seed.deriver")],
EncryptionType.Crypter.values()[rs.getInt("seed.crypter")]);
DeterministicSeed seed = new DeterministicSeed(encryptedData, rs.getBoolean("seed.needsPassphrase"), rs.getLong("seed.creationTimeSeconds"), DeterministicSeed.Type.values()[rs.getInt("seed.type")]);
seed.setId(rs.getLong("seed.id"));
keystore.setSeed(seed);
}
return keystore;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.policy.Policy;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
public interface PolicyDao {
@SqlQuery("select * from policy where id = ?")
@RegisterRowMapper(PolicyMapper.class)
Policy getPolicy(long id);
@SqlUpdate("insert into policy (name, script) values (?, ?)")
@GetGeneratedKeys("id")
long insert(String name, String script);
default void addPolicy(Policy policy) {
long id = insert(policy.getName(), policy.getMiniscript().getScript());
policy.setId(id);
}
}
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.policy.Miniscript;
import com.sparrowwallet.drongo.policy.Policy;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class PolicyMapper implements RowMapper<Policy> {
@Override
public Policy map(ResultSet rs, StatementContext ctx) throws SQLException {
Policy policy = new Policy(rs.getString("name"), new Miniscript(rs.getString("script")));
policy.setId(rs.getLong("id"));
return policy;
}
}
@@ -0,0 +1,106 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.sqlobject.CreateSqlObject;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public interface WalletDao {
@CreateSqlObject
PolicyDao createPolicyDao();
@CreateSqlObject
KeystoreDao createKeystoreDao();
@CreateSqlObject
WalletNodeDao createWalletNodeDao();
@CreateSqlObject
BlockTransactionDao createBlockTransactionDao();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id")
@RegisterRowMapper(WalletMapper.class)
List<Wallet> loadAllWallets();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id = 1")
@RegisterRowMapper(WalletMapper.class)
Wallet loadMainWallet();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id != 1")
@RegisterRowMapper(WalletMapper.class)
List<Wallet> loadChildWallets();
@SqlUpdate("insert into wallet (name, network, policyType, scriptType, storedBlockHeight, gapLimit, birthDate, defaultPolicy) values (?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insert(String name, int network, int policyType, int scriptType, Integer storedBlockHeight, Integer gapLimit, Date birthDate, long defaultPolicy);
@SqlUpdate("update wallet set storedBlockHeight = :blockHeight where id = :id")
void updateStoredBlockHeight(@Bind("id") long id, @Bind("blockHeight") Integer blockHeight);
@SqlUpdate("set schema ?")
int setSchema(String schema);
default Wallet getMainWallet(String schema) {
try {
setSchema(schema);
Wallet mainWallet = loadMainWallet();
if(mainWallet != null) {
loadWallet(mainWallet);
}
return mainWallet;
} finally {
setSchema(DbPersistence.DEFAULT_SCHEMA);
}
}
default List<Wallet> getChildWallets(String schema) {
try {
List<Wallet> childWallets = loadChildWallets();
for(Wallet childWallet : childWallets) {
loadWallet(childWallet);
}
return childWallets;
} finally {
setSchema(DbPersistence.DEFAULT_SCHEMA);
}
}
default void loadWallet(Wallet wallet) {
wallet.getKeystores().addAll(createKeystoreDao().getForWalletId(wallet.getId()));
List<WalletNode> walletNodes = createWalletNodeDao().getForWalletId(wallet.getId());
wallet.getPurposeNodes().addAll(walletNodes.stream().filter(walletNode -> walletNode.getDerivation().size() == 1).collect(Collectors.toList()));
Map<Sha256Hash, BlockTransaction> blockTransactions = createBlockTransactionDao().getForWalletId(wallet.getId()); //.stream().collect(Collectors.toMap(BlockTransaction::getHash, Function.identity(), (existing, replacement) -> existing, LinkedHashMap::new));
wallet.updateTransactions(blockTransactions);
}
default void addWallet(String schema, Wallet wallet) {
try {
setSchema(schema);
createPolicyDao().addPolicy(wallet.getDefaultPolicy());
long id = insert(wallet.getName(), wallet.getNetwork().ordinal(), wallet.getPolicyType().ordinal(), wallet.getScriptType().ordinal(), wallet.getStoredBlockHeight(), wallet.gapLimit(), wallet.getBirthDate(), wallet.getDefaultPolicy().getId());
wallet.setId(id);
createKeystoreDao().addKeystores(wallet);
createWalletNodeDao().addWalletNodes(wallet);
createBlockTransactionDao().addBlockTransactions(wallet);
} finally {
setSchema(DbPersistence.DEFAULT_SCHEMA);
}
}
}
@@ -0,0 +1,37 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.policy.Miniscript;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class WalletMapper implements RowMapper<Wallet> {
@Override
public Wallet map(ResultSet rs, StatementContext ctx) throws SQLException {
Wallet wallet = new Wallet(rs.getString("wallet.name"));
wallet.setId(rs.getLong("wallet.id"));
wallet.setNetwork(Network.values()[rs.getInt("wallet.network")]);
wallet.setPolicyType(PolicyType.values()[rs.getInt("wallet.policyType")]);
wallet.setScriptType(ScriptType.values()[rs.getInt("wallet.scriptType")]);
Policy policy = new Policy(rs.getString("policy.name"), new Miniscript(rs.getString("policy.script")));
policy.setId(rs.getLong("policy.id"));
wallet.setDefaultPolicy(policy);
int storedBlockHeight = rs.getInt("wallet.storedBlockHeight");
wallet.setStoredBlockHeight(rs.wasNull() ? null : storedBlockHeight);
int gapLimit = rs.getInt("wallet.gapLimit");
wallet.gapLimit(rs.wasNull() ? null : gapLimit);
wallet.setBirthDate(rs.getTimestamp("wallet.birthDate"));
return wallet;
}
}
@@ -0,0 +1,116 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.customizer.BindList;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.jdbi.v3.sqlobject.statement.UseRowReducer;
import java.util.Date;
import java.util.List;
public interface WalletNodeDao {
@SqlQuery("select walletNode.id, walletNode.derivationPath, walletNode.label, walletNode.parent, " +
"blockTransactionHashIndex.id, blockTransactionHashIndex.hash, blockTransactionHashIndex.height, blockTransactionHashIndex.date, blockTransactionHashIndex.fee, blockTransactionHashIndex.label, " +
"blockTransactionHashIndex.index, blockTransactionHashIndex.value, blockTransactionHashIndex.status, blockTransactionHashIndex.spentBy, blockTransactionHashIndex.node " +
"from walletNode left join blockTransactionHashIndex on walletNode.id = blockTransactionHashIndex.node where walletNode.wallet = ? order by walletNode.parent asc nulls first, blockTransactionHashIndex.spentBy asc nulls first")
@RegisterRowMapper(WalletNodeMapper.class)
@RegisterRowMapper(BlockTransactionHashIndexMapper.class)
@UseRowReducer(WalletNodeReducer.class)
List<WalletNode> getForWalletId(Long id);
@SqlUpdate("insert into walletNode (derivationPath, label, wallet, parent) values (?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertWalletNode(String derivationPath, String label, long wallet, Long parent);
@SqlUpdate("insert into blockTransactionHashIndex (hash, height, date, fee, label, index, value, status, spentBy, node) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertBlockTransactionHashIndex(byte[] hash, int height, Date date, Long fee, String label, long index, long value, Integer status, Long spentBy, long node);
@SqlUpdate("update blockTransactionHashIndex set hash = ?, height = ?, date = ?, fee = ?, label = ?, index = ?, value = ?, status = ?, spentBy = ?, node = ? where id = ?")
void updateBlockTransactionHashIndex(byte[] hash, int height, Date date, Long fee, String label, long index, long value, Integer status, Long spentBy, long node, long id);
@SqlUpdate("update walletNode set label = :label where id = :id")
void updateNodeLabel(@Bind("id") long id, @Bind("label") String label);
@SqlUpdate("update blockTransactionHashIndex set label = :label where id = :id")
void updateTxoLabel(@Bind("id") long id, @Bind("label") String label);
@SqlUpdate("update blockTransactionHashIndex set status = :status where id = :id")
void updateTxoStatus(@Bind("id") long id, @Bind("status") Integer status);
@SqlUpdate("delete from blockTransactionHashIndex where blockTransactionHashIndex.node in (select walletNode.id from walletNode where walletNode.wallet = ?)")
void clearHistory(long wallet);
@SqlUpdate("delete from blockTransactionHashIndex where blockTransactionHashIndex.node in (select walletNode.id from walletNode where walletNode.wallet = ?) and blockTransactionHashIndex.spentBy is not null")
void clearSpentHistory(long wallet);
@SqlUpdate("delete from blockTransactionHashIndex where node = :nodeId and id not in (<ids>)")
void deleteUnreferencedNodeTxos(@Bind("nodeId") Long nodeId, @BindList("ids") List<Long> ids);
@SqlUpdate("delete from blockTransactionHashIndex where node = :nodeId and id not in (<ids>) and spentBy is not null")
void deleteUnreferencedNodeSpentTxos(@Bind("nodeId") Long nodeId, @BindList("ids") List<Long> ids);
default void addWalletNodes(Wallet wallet) {
for(WalletNode purposeNode : wallet.getPurposeNodes()) {
long purposeNodeId = insertWalletNode(purposeNode.getDerivationPath(), purposeNode.getLabel(), wallet.getId(), null);
purposeNode.setId(purposeNodeId);
for(WalletNode addressNode : purposeNode.getChildren()) {
long addressNodeId = insertWalletNode(addressNode.getDerivationPath(), addressNode.getLabel(), wallet.getId(), purposeNodeId);
addressNode.setId(addressNodeId);
addTransactionOutputs(addressNode);
}
}
}
default void addTransactionOutputs(WalletNode addressNode) {
for(BlockTransactionHashIndex txo : addressNode.getTransactionOutputs()) {
txo.setId(null);
if(txo.isSpent()) {
txo.getSpentBy().setId(null);
}
addOrUpdate(addressNode, txo);
}
}
default void addOrUpdate(WalletNode addressNode, BlockTransactionHashIndex txo) {
Long spentById = null;
if(txo.isSpent()) {
BlockTransactionHashIndex spentBy = txo.getSpentBy();
if(spentBy.getId() == null) {
spentById = insertBlockTransactionHashIndex(spentBy.getHash().getBytes(), spentBy.getHeight(), spentBy.getDate(), spentBy.getFee(), spentBy.getLabel(), spentBy.getIndex(), spentBy.getValue(),
spentBy.getStatus() == null ? null : spentBy.getStatus().ordinal(), null, addressNode.getId());
spentBy.setId(spentById);
} else {
updateBlockTransactionHashIndex(spentBy.getHash().getBytes(), spentBy.getHeight(), spentBy.getDate(), spentBy.getFee(), spentBy.getLabel(), spentBy.getIndex(), spentBy.getValue(),
spentBy.getStatus() == null ? null : spentBy.getStatus().ordinal(), null, addressNode.getId(), spentBy.getId());
spentById = spentBy.getId();
}
}
if(txo.getId() == null) {
long txoId = insertBlockTransactionHashIndex(txo.getHash().getBytes(), txo.getHeight(), txo.getDate(), txo.getFee(), txo.getLabel(), txo.getIndex(), txo.getValue(),
txo.getStatus() == null ? null : txo.getStatus().ordinal(), spentById, addressNode.getId());
txo.setId(txoId);
} else {
updateBlockTransactionHashIndex(txo.getHash().getBytes(), txo.getHeight(), txo.getDate(), txo.getFee(), txo.getLabel(), txo.getIndex(), txo.getValue(),
txo.getStatus() == null ? null : txo.getStatus().ordinal(), spentById, addressNode.getId(), txo.getId());
}
}
default void deleteNodeTxosNotInList(WalletNode addressNode, List<Long> txoIds) {
deleteUnreferencedNodeSpentTxos(addressNode.getId(), txoIds);
deleteUnreferencedNodeTxos(addressNode.getId(), txoIds);
}
default void clearHistory(Wallet wallet) {
clearSpentHistory(wallet.getId());
clearHistory(wallet.getId());
}
}
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class WalletNodeMapper implements RowMapper<WalletNode> {
@Override
public WalletNode map(ResultSet rs, StatementContext ctx) throws SQLException {
WalletNode walletNode = new WalletNode(rs.getString("walletNode.derivationPath"));
walletNode.setId(rs.getLong("walletNode.id"));
walletNode.setLabel(rs.getString("walletNode.label"));
return walletNode;
}
}
@@ -0,0 +1,30 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.core.result.LinkedHashMapRowReducer;
import org.jdbi.v3.core.result.RowView;
import java.util.Map;
public class WalletNodeReducer implements LinkedHashMapRowReducer<Long, WalletNode> {
@Override
public void accumulate(Map<Long, WalletNode> map, RowView rowView) {
WalletNode walletNode = map.computeIfAbsent(rowView.getColumn("walletNode.id", Long.class), id -> rowView.getRow(WalletNode.class));
if(rowView.getColumn("walletNode.parent", Long.class) != null) {
WalletNode parentNode = map.get(rowView.getColumn("walletNode.parent", Long.class));
parentNode.getChildren().add(walletNode);
}
if(rowView.getColumn("blockTransactionHashIndex.node", Long.class) != null) {
BlockTransactionHashIndex blockTransactionHashIndex = rowView.getRow(BlockTransactionHashIndex.class);
if(rowView.getColumn("blockTransactionHashIndex.spentBy", Long.class) != null) {
BlockTransactionHashIndex spentBy = walletNode.getTransactionOutputs().stream().filter(ref -> ref.getId().equals(rowView.getColumn("blockTransactionHashIndex.spentBy", Long.class))).findFirst().orElseThrow();
blockTransactionHashIndex.setSpentBy(spentBy);
walletNode.getTransactionOutputs().remove(spentBy);
}
walletNode.getTransactionOutputs().add(blockTransactionHashIndex);
}
}
}
@@ -269,7 +269,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(idCounter.incrementAndGet()).param("raw_tx", txHex).execute());
client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(idCounter.incrementAndGet()).params(txHex).execute());
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException(e.getErrorMessage().getMessage(), e);
} catch(Exception e) {
@@ -45,7 +45,7 @@ public class ElectrumServer {
private static Transport transport;
private static final Map<String, Set<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static final Map<String, List<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static String previousServerAddress;
@@ -822,21 +822,21 @@ public class ElectrumServer {
return Utils.bytesToHex(reversed);
}
public static Map<String, Set<String>> getSubscribedScriptHashes() {
public static Map<String, List<String>> getSubscribedScriptHashes() {
return subscribedScriptHashes;
}
public static String getSubscribedScriptHashStatus(String scriptHash) {
Set<String> existingStatuses = subscribedScriptHashes.get(scriptHash);
if(existingStatuses != null) {
return Iterables.getLast(existingStatuses);
List<String> existingStatuses = subscribedScriptHashes.get(scriptHash);
if(existingStatuses != null && !existingStatuses.isEmpty()) {
return existingStatuses.get(existingStatuses.size() - 1);
}
return null;
}
public static void updateSubscribedScriptHashStatus(String scriptHash, String status) {
Set<String> existingStatuses = subscribedScriptHashes.computeIfAbsent(scriptHash, k -> new LinkedHashSet<>());
List<String> existingStatuses = subscribedScriptHashes.computeIfAbsent(scriptHash, k -> new ArrayList<>());
existingStatuses.add(status);
}
@@ -1147,11 +1147,22 @@ public class ElectrumServer {
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
//Add all of the script hashes we have now fetched the history for so we don't need to fetch again until the script hash status changes
for(WalletNode node : nodeTransactionMap.keySet()) {
for(WalletNode node : (nodes == null ? nodeTransactionMap.keySet() : nodes)) {
String scriptHash = getScriptHash(wallet, node);
retrievedScriptHashes.put(scriptHash, getSubscribedScriptHashStatus(scriptHash));
}
//Clear transaction outputs for nodes that have no history - this is useful when a transaction is replaced in the mempool
if(nodes != null) {
for(WalletNode node : nodes) {
String scriptHash = getScriptHash(wallet, node);
if(retrievedScriptHashes.get(scriptHash) == null && !node.getTransactionOutputs().isEmpty()) {
log.debug("Clearing transaction history for " + node);
node.getTransactionOutputs().clear();
}
}
}
return true;
}
@@ -12,7 +12,7 @@ import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Set;
import java.util.List;
@JsonRpcService
public class SubscriptionService {
@@ -25,16 +25,11 @@ public class SubscriptionService {
@JsonRpcMethod("blockchain.scripthash.subscribe")
public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcOptional @JsonRpcParam("status") final String status) {
if(status == null) {
//Mempool transaction was replaced returning change/consolidation script hash status to null, ignore this update
return;
}
Set<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
List<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
if(existingStatuses == null) {
log.debug("Received script hash status update for unsubscribed script hash: " + scriptHash);
ElectrumServer.updateSubscribedScriptHashStatus(scriptHash, status);
} else if(existingStatuses.contains(status)) {
} else if(status != null && existingStatuses.contains(status)) {
log.debug("Received script hash status update, but status has not changed");
return;
} else {
@@ -709,7 +709,7 @@ public class HeadersController extends TransactionFormController implements Init
}
Wallet copy = headersForm.getSigningWallet().copy();
File file = headersForm.getAvailableWallets().get(headersForm.getSigningWallet()).getWalletFile();
String walletId = headersForm.getAvailableWallets().get(headersForm.getSigningWallet()).getWalletId(headersForm.getSigningWallet());
if(copy.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
@@ -717,15 +717,15 @@ public class HeadersController extends TransactionFormController implements Init
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
signUnencryptedKeystores(decryptedWallet);
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
@@ -26,8 +26,10 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
this.keyPurpose = keyPurpose;
labelProperty().addListener((observable, oldValue, newValue) -> {
hashIndex.setLabel(newValue);
EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, this));
if(!Objects.equals(hashIndex.getLabel(), newValue)) {
hashIndex.setLabel(newValue);
EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, this));
}
});
}
@@ -346,15 +346,15 @@ public class KeystoreController extends WalletFormController implements Initiali
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(getWalletForm().getWalletFile(), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(getWalletForm().getWalletId(), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
showPrivate(decryptedWallet.getKeystores().get(keystoreIndex));
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(getWalletForm().getWalletFile(), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(getWalletForm().getWalletId(), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(getWalletForm().getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(getWalletForm().getWalletId(), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
@@ -2,14 +2,14 @@ package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.io.Config;
import java.util.List;
import java.util.Objects;
import java.util.*;
import java.util.stream.Collectors;
public class NodeEntry extends Entry implements Comparable<NodeEntry> {
@@ -20,8 +20,10 @@ public class NodeEntry extends Entry implements Comparable<NodeEntry> {
this.node = node;
labelProperty().addListener((observable, oldValue, newValue) -> {
node.setLabel(newValue);
EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, this));
if(!Objects.equals(node.getLabel(), newValue)) {
node.setLabel(newValue);
EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, this));
}
});
}
@@ -78,4 +80,45 @@ public class NodeEntry extends Entry implements Comparable<NodeEntry> {
public int compareTo(NodeEntry other) {
return node.compareTo(other.node);
}
public Set<Entry> copyLabels(WalletNode pastNode) {
if(pastNode == null) {
return Collections.emptySet();
}
Set<Entry> changedEntries = new LinkedHashSet<>();
if(node.getLabel() == null && pastNode.getLabel() != null) {
node.setLabel(pastNode.getLabel());
labelProperty().set(pastNode.getLabel());
changedEntries.add(this);
}
for(Entry childEntry : getChildren()) {
if(childEntry instanceof HashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)childEntry;
BlockTransactionHashIndex txo = hashIndexEntry.getHashIndex();
Optional<BlockTransactionHashIndex> optPastTxo = pastNode.getTransactionOutputs().stream().filter(pastTxo -> pastTxo.equals(txo)).findFirst();
if(optPastTxo.isPresent()) {
BlockTransactionHashIndex pastTxo = optPastTxo.get();
if(txo.getLabel() == null && pastTxo.getLabel() != null) {
txo.setLabel(pastTxo.getLabel());
changedEntries.add(childEntry);
}
if(txo.isSpent() && pastTxo.isSpent() && txo.getSpentBy().getLabel() == null && pastTxo.getSpentBy().getLabel() != null) {
txo.getSpentBy().setLabel(pastTxo.getSpentBy().getLabel());
changedEntries.add(childEntry);
}
}
}
if(childEntry instanceof NodeEntry) {
NodeEntry childNodeEntry = (NodeEntry)childEntry;
Optional<WalletNode> optPastChildNodeEntry = pastNode.getChildren().stream().filter(childNodeEntry.node::equals).findFirst();
optPastChildNodeEntry.ifPresent(pastChildNode -> changedEntries.addAll(childNodeEntry.copyLabels(pastChildNode)));
}
}
return changedEntries;
}
}
@@ -17,6 +17,7 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.StorageException;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
@@ -399,7 +400,16 @@ public class SettingsController extends WalletFormController implements Initiali
Optional<Wallet> optWallet = AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile())).map(Map.Entry::getKey).findFirst();
if(optWallet.isPresent()) {
WalletExportDialog dlg = new WalletExportDialog(optWallet.get());
Wallet wallet = optWallet.get();
if(!walletForm.getWallet().getName().equals(wallet.getName())) {
wallet = wallet.getChildWallet(walletForm.getWallet().getName());
}
if(wallet == null) {
throw new IllegalStateException("Cannot find child wallet " + walletForm.getWallet().getName() + " to export");
}
WalletExportDialog dlg = new WalletExportDialog(wallet);
dlg.showAndWait();
} else {
AppServices.showErrorDialog("Cannot export wallet", "Wallet cannot be exported, please save it first.");
@@ -441,25 +451,23 @@ public class SettingsController extends WalletFormController implements Initiali
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
if(event.getWalletFile().equals(walletForm.getWalletFile())) {
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
if(event.getWalletId().equals(walletForm.getWalletId())) {
export.setDisable(!event.getWallet().isValid());
scanDescriptorQR.setVisible(!event.getWallet().isValid());
updateBirthDate(event.getWallet());
}
}
@Subscribe
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
updateBirthDate(event.getWalletFile(), event.getWallet());
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
updateBirthDate(event.getWalletFile(), event.getWallet());
if(event.getWalletId().equals(walletForm.getWalletId())) {
updateBirthDate(event.getWallet());
}
}
private void updateBirthDate(File walletFile, Wallet wallet) {
if(walletFile.equals(walletForm.getWalletFile()) && !Objects.equals(wallet.getBirthDate(), walletForm.getWallet().getBirthDate())) {
private void updateBirthDate(Wallet wallet) {
if(!Objects.equals(wallet.getBirthDate(), walletForm.getWallet().getBirthDate())) {
walletForm.getWallet().setBirthDate(wallet.getBirthDate());
}
}
@@ -509,7 +517,7 @@ public class SettingsController extends WalletFormController implements Initiali
walletForm.getStorage().setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
walletForm.saveAndRefresh();
EventManager.get().post(new RequestOpenWalletsEvent());
} catch (IOException e) {
} catch (IOException | StorageException e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet", e.getMessage());
revert.setDisable(false);
@@ -518,7 +526,7 @@ public class SettingsController extends WalletFormController implements Initiali
} else {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(walletForm.getStorage(), password.get());
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletForm.getWalletFile(), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(walletForm.getWalletId(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = null;
@@ -566,12 +574,12 @@ public class SettingsController extends WalletFormController implements Initiali
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletForm.getWalletFile(), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(walletForm.getWalletId(), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Error saving wallet", keyDerivationService.getException().getMessage());
revert.setDisable(false);
apply.setDisable(false);
});
EventManager.get().post(new StorageEvent(walletForm.getWalletFile(), TimedEvent.Action.START, "Encrypting wallet..."));
EventManager.get().post(new StorageEvent(walletForm.getWalletId(), TimedEvent.Action.START, "Encrypting wallet..."));
keyDerivationService.start();
}
} else {
@@ -5,11 +5,15 @@ import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletPasswordChangedEvent;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.StorageException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
@@ -40,30 +44,65 @@ public class SettingsWalletForm extends WalletForm {
}
@Override
public void saveAndRefresh() throws IOException {
Wallet pastWallet = null;
public void saveAndRefresh() throws IOException, StorageException {
Wallet pastWallet = wallet.copy();
boolean refreshAll = isRefreshNecessary(wallet, walletCopy);
if(refreshAll) {
pastWallet = wallet.copy();
save(); //Save here for the temp backup in case password has been changed
if(AppServices.isConnected()) {
getStorage().backupTempWallet();
if(isRefreshNecessary(wallet, walletCopy)) {
boolean addressChange = isAddressChange();
if(wallet.isValid()) {
//Don't create temp backup on changing addresses - there are no labels to lose
if(!addressChange) {
backgroundUpdate(); //Save existing wallet here for the temp backup in case password has been changed - this will update the password on the existing wallet
if(AppServices.isConnected()) {
//Backup the wallet so labels will survive application shutdown
getStorage().backupTempWallet();
}
}
//Clear transaction history cache before we clear the nodes
AppServices.clearTransactionHistoryCache(wallet);
}
//Clear node tree
walletCopy.clearNodes();
}
wallet = walletCopy.copy();
save();
Integer childIndex = wallet.isMasterWallet() ? null : wallet.getMasterWallet().getChildWallets().indexOf(wallet);
if(refreshAll) {
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, getWalletFile()));
//Replace the SettingsWalletForm wallet reference - note that this reference is only shared with the WalletForm wallet with WalletAddressesChangedEvent below
wallet = walletCopy.copy();
if(wallet.isMasterWallet()) {
wallet.getChildWallets().forEach(childWallet -> childWallet.setMasterWallet(wallet));
} else if(childIndex != null) {
wallet.getMasterWallet().getChildWallets().set(childIndex, wallet);
}
save();
EventManager.get().post(new WalletAddressesChangedEvent(wallet, addressChange ? null : pastWallet, getWalletId()));
} else {
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, getWalletFile()));
List<Keystore> changedKeystores = new ArrayList<>();
for(int i = 0; i < wallet.getKeystores().size(); i++) {
Keystore keystore = wallet.getKeystores().get(i);
Keystore keystoreCopy = walletCopy.getKeystores().get(i);
if(!Objects.equals(keystore.getLabel(), keystoreCopy.getLabel())) {
keystore.setLabel(keystoreCopy.getLabel());
changedKeystores.add(keystore);
}
}
if(!changedKeystores.isEmpty()) {
EventManager.get().post(new KeystoreLabelsChangedEvent(wallet, pastWallet, getWalletId(), changedKeystores));
} else {
//Can only be a password update at this point
EventManager.get().post(new WalletPasswordChangedEvent(wallet, pastWallet, getWalletId()));
}
}
}
//Returns true for any change, other than a keystore label change, to trigger a full wallet refresh
//Even though this is not strictly necessary for some changes, it it better to refresh on saving so background transaction history updates on the old wallet have no effect/are not lost
private boolean isRefreshNecessary(Wallet original, Wallet changed) {
if(!original.isValid() || !changed.isValid()) {
return true;
@@ -73,6 +112,27 @@ public class SettingsWalletForm extends WalletForm {
return true;
}
for(int i = 0; i < original.getKeystores().size(); i++) {
Keystore originalKeystore = original.getKeystores().get(i);
Keystore changedKeystore = changed.getKeystores().get(i);
if(originalKeystore.getSource() != changedKeystore.getSource()) {
return true;
}
if(originalKeystore.getWalletModel() != changedKeystore.getWalletModel()) {
return true;
}
if((originalKeystore.getSeed() == null && changedKeystore.getSeed() != null) || (originalKeystore.getSeed() != null && changedKeystore.getSeed() == null)) {
return true;
}
if((originalKeystore.getMasterPrivateExtendedKey() == null && changedKeystore.getMasterPrivateExtendedKey() != null) || (originalKeystore.getMasterPrivateExtendedKey() != null && changedKeystore.getMasterPrivateExtendedKey() == null)) {
return true;
}
}
if(original.getGapLimit() != changed.getGapLimit()) {
return true;
}
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.WalletBlockHeightChangedEvent;
@@ -30,8 +31,10 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
this.blockTransaction = blockTransaction;
labelProperty().addListener((observable, oldValue, newValue) -> {
blockTransaction.setLabel(newValue);
EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, this));
if(!Objects.equals(blockTransaction.getLabel(), newValue)) {
blockTransaction.setLabel(newValue);
EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, this));
}
});
setConfirmations(calculateConfirmations());
@@ -68,7 +71,7 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
}
public int calculateConfirmations() {
return blockTransaction.getConfirmations(getWallet().getStoredBlockHeight());
return blockTransaction.getConfirmations(AppServices.getCurrentBlockHeight() == null ? getWallet().getStoredBlockHeight() : AppServices.getCurrentBlockHeight());
}
public String getConfirmationsDescription() {
@@ -107,7 +107,7 @@ public class WalletController extends WalletFormController implements Initializa
@Subscribe
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
if(event.getWalletFile().equals(walletForm.getWalletFile())) {
if(event.getWalletId().equals(walletForm.getWalletId())) {
configure(event.getWallet().isValid());
}
}
@@ -12,6 +12,7 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.StorageException;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
@@ -22,6 +23,7 @@ import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
public class WalletForm {
private static final Logger log = LoggerFactory.getLogger(WalletForm.class);
@@ -60,6 +62,10 @@ public class WalletForm {
return storage;
}
public String getWalletId() {
return storage.getWalletId(wallet);
}
public File getWalletFile() {
return storage.getWalletFile();
}
@@ -72,11 +78,11 @@ public class WalletForm {
throw new UnsupportedOperationException("Only SettingsWalletForm supports revert");
}
public void save() throws IOException {
public void save() throws IOException, StorageException {
storage.saveWallet(wallet);
}
public void saveAndRefresh() throws IOException {
public void saveAndRefresh() throws IOException, StorageException {
Wallet pastWallet = wallet.copy();
storage.backupTempWallet();
wallet.clearHistory();
@@ -88,6 +94,15 @@ public class WalletForm {
storage.backupWallet();
}
protected void backgroundUpdate() {
try {
storage.updateWallet(wallet);
} catch (IOException | StorageException e) {
//Background save failed
log.error("Background wallet save failed", e);
}
}
public void deleteBackups() {
storage.deleteBackups();
}
@@ -127,36 +142,61 @@ public class WalletForm {
wallet.setStoredBlockHeight(blockHeight);
}
boolean labelsChanged = false;
//After the wallet settings are changed, the previous wallet is copied to pastWallet and used here to copy labels from past nodes, txos and txes
Set<Entry> labelChangedEntries = Collections.emptySet();
if(pastWallet != null) {
labelsChanged = copyLabels(pastWallet);
labelChangedEntries = copyLabels(pastWallet);
}
notifyIfChanged(blockHeight, previousWallet, labelsChanged);
notifyIfChanged(blockHeight, previousWallet, labelChangedEntries);
}
private boolean copyLabels(Wallet pastWallet) {
boolean changed = wallet.getNode(KeyPurpose.RECEIVE).copyLabels(pastWallet.getNode(KeyPurpose.RECEIVE));
changed |= wallet.getNode(KeyPurpose.CHANGE).copyLabels(pastWallet.getNode(KeyPurpose.CHANGE));
private Set<Entry> copyLabels(Wallet pastWallet) {
Set<Entry> changedEntries = new LinkedHashSet<>();
//On a full wallet refresh, walletUtxosEntry and walletTransactionsEntry will have no children yet, but AddressesController may have created accountEntries on a walletNodesChangedEvent
//Copy nodeEntry labels
List<KeyPurpose> keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE);
for(KeyPurpose keyPurpose : keyPurposes) {
NodeEntry purposeEntry = getNodeEntry(keyPurpose);
changedEntries.addAll(purposeEntry.copyLabels(pastWallet.getNode(purposeEntry.getNode().getKeyPurpose())));
}
//Copy node and txo labels
for(KeyPurpose keyPurpose : keyPurposes) {
if(wallet.getNode(keyPurpose).copyLabels(pastWallet.getNode(keyPurpose))) {
changedEntries.add(getWalletUtxosEntry());
}
}
//Copy tx labels
for(Map.Entry<Sha256Hash, BlockTransaction> txEntry : wallet.getTransactions().entrySet()) {
BlockTransaction pastBlockTransaction = pastWallet.getTransactions().get(txEntry.getKey());
if(pastBlockTransaction != null && txEntry.getValue() != null && txEntry.getValue().getLabel() == null && pastBlockTransaction.getLabel() != null) {
txEntry.getValue().setLabel(pastBlockTransaction.getLabel());
changed = true;
changedEntries.add(getWalletTransactionsEntry());
}
}
storage.deleteTempBackups();
return changed;
return changedEntries;
}
private void notifyIfChanged(Integer blockHeight, Wallet previousWallet, boolean labelsChanged) {
private void notifyIfChanged(Integer blockHeight, Wallet previousWallet, Set<Entry> labelChangedEntries) {
List<WalletNode> historyChangedNodes = new ArrayList<>();
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.RECEIVE).getChildren(), wallet.getNode(KeyPurpose.RECEIVE).getChildren()));
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.CHANGE).getChildren(), wallet.getNode(KeyPurpose.CHANGE).getChildren()));
boolean changed = labelsChanged;
boolean changed = false;
if(!labelChangedEntries.isEmpty()) {
List<Entry> eventEntries = labelChangedEntries.stream().filter(entry -> entry != getWalletTransactionsEntry() && entry != getWalletUtxosEntry()).collect(Collectors.toList());
if(!eventEntries.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, eventEntries)));
}
changed = true;
}
if(!historyChangedNodes.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(wallet, storage, historyChangedNodes)));
changed = true;
@@ -257,39 +297,43 @@ public class WalletForm {
@Subscribe
public void walletDataChanged(WalletDataChangedEvent event) {
if(event.getWallet().equals(wallet)) {
backgroundSaveWallet();
}
}
private void backgroundSaveWallet() {
try {
save();
} catch (IOException e) {
//Background save failed
log.error("Background wallet save failed", e);
backgroundUpdate();
}
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
if(event.getWalletFile().equals(storage.getWalletFile())) {
public void walletHistoryCleared(WalletHistoryClearedEvent event) {
if(event.getWalletId().equals(getWalletId())) {
//Replacing the WalletForm's wallet here is only possible because we immediately clear all derived structures and do a full wallet refresh
wallet = event.getWallet();
if(event instanceof WalletAddressesChangedEvent) {
walletTransactionsEntry = null;
walletUtxosEntry = null;
accountEntries.clear();
EventManager.get().post(new WalletNodesChangedEvent(wallet));
walletTransactionsEntry = null;
walletUtxosEntry = null;
accountEntries.clear();
EventManager.get().post(new WalletNodesChangedEvent(wallet));
//It is necessary to save the past wallet because the actual copying of the past labels only occurs on a later ConnectionEvent with bwt
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
savedPastWallet = event.getPastWallet();
}
//Clear the cache - we will need to fetch everything again
AppServices.clearTransactionHistoryCache(wallet);
refreshHistory(AppServices.getCurrentBlockHeight(), event.getPastWallet());
//It is necessary to save the past wallet because the actual copying of the past labels only occurs on a later ConnectionEvent with bwt
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
savedPastWallet = event.getPastWallet();
}
//Clear the cache - we will need to fetch everything again
AppServices.clearTransactionHistoryCache(wallet);
refreshHistory(AppServices.getCurrentBlockHeight(), event.getPastWallet());
}
}
@Subscribe
public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) {
if(event.getWalletId().equals(getWalletId())) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
}
}
@Subscribe
public void walletPasswordChanged(WalletPasswordChangedEvent event) {
if(event.getWalletId().equals(getWalletId())) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
}
}
@@ -320,7 +364,7 @@ public class WalletForm {
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(event.getWalletFile().equals(storage.getWalletFile())) {
if(event.getWalletId().equals(getWalletId())) {
for(WalletNode changedNode : event.getHistoryChangedNodes()) {
if(changedNode.getLabel() != null && !changedNode.getLabel().isEmpty()) {
List<Entry> changedLabelEntries = new ArrayList<>();
@@ -393,14 +437,24 @@ public class WalletForm {
if(!labelChangedEntries.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, labelChangedEntries)));
} else {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
}
}
}
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(event.getWallet() == wallet) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
}
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData tabData : event.getClosedWalletTabData()) {
if(tabData.getWalletForm() == this) {
storage.close();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().unregister(this);
}
+6 -1
View File
@@ -23,11 +23,16 @@ open module com.sparrowwallet.sparrow {
requires hummingbird;
requires centerdevice.nsmenufx;
requires jcommander;
requires slf4j.api;
requires org.slf4j;
requires bwt.jni;
requires jtorctl;
requires javacsv;
requires jul.to.slf4j;
requires bridj;
requires com.google.gson;
requires org.jdbi.v3.core;
requires org.jdbi.v3.sqlobject;
requires org.flywaydb.core;
requires com.zaxxer.hikari;
requires com.h2database;
}