diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixDialog.java new file mode 100644 index 00000000..3e2d4f9b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixDialog.java @@ -0,0 +1,124 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.samourai.whirlpool.client.wallet.beans.Tx0FeeTarget; +import com.samourai.whirlpool.client.whirlpool.beans.Pool; +import com.sparrowwallet.drongo.wallet.MixConfig; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.WalletMasterMixConfigChangedEvent; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; +import com.sparrowwallet.sparrow.wallet.WalletForm; +import com.sparrowwallet.sparrow.whirlpool.dataSource.SparrowMinerFeeSupplier; + +import java.util.List; +import java.util.Locale; + +public class MixDialog extends WalletDialog { + private static final List FEE_PRIORITIES = List.of(new FeePriority("Low", Tx0FeeTarget.MIN), new FeePriority("Normal", Tx0FeeTarget.BLOCKS_4), new FeePriority("High", Tx0FeeTarget.BLOCKS_2)); + + private final String walletId; + private final List utxoEntries; + + private final TextBox scode; + private final ComboBox premixPriority; + private final Label premixFeeRate; + + private Pool mixPool; + + public MixDialog(String walletId, WalletForm walletForm, List utxoEntries) { + super(walletForm.getWallet().getFullDisplayName() + " Premix Config", walletForm); + + this.walletId = walletId; + this.utxoEntries = utxoEntries; + + setHints(List.of(Hint.CENTERED)); + + Wallet wallet = walletForm.getWallet(); + MixConfig mixConfig = wallet.getMasterMixConfig(); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(5).setVerticalSpacing(1)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new Label("SCODE")); + scode = new TextBox(new TerminalSize(20, 1)); + mainPanel.addComponent(scode); + + mainPanel.addComponent(new Label("Premix priority")); + premixPriority = new ComboBox<>(); + FEE_PRIORITIES.forEach(premixPriority::addItem); + mainPanel.addComponent(premixPriority); + + mainPanel.addComponent(new Label("Premix fee rate")); + premixFeeRate = new Label(""); + mainPanel.addComponent(premixFeeRate); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Cancel", this::onCancel)); + Button next = new Button("Next", this::onNext).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false)); + buttonPanel.addComponent(next); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + + scode.setText(mixConfig.getScode() == null ? "" : mixConfig.getScode()); + + premixPriority.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + FeePriority feePriority = premixPriority.getItem(selectedIndex); + premixFeeRate.setText(SparrowMinerFeeSupplier.getFee(Integer.parseInt(feePriority.getTx0FeeTarget().getFeeTarget().getValue())) + " sats/vB"); + }); + premixPriority.setSelectedIndex(1); + + scode.setTextChangeListener((newText, changedByUserInteraction) -> { + if(changedByUserInteraction) { + scode.setText(newText.toUpperCase(Locale.ROOT)); + } + + mixConfig.setScode(newText.toUpperCase(Locale.ROOT)); + EventManager.get().post(new WalletMasterMixConfigChangedEvent(wallet)); + }); + } + + private void onNext() { + MixPoolDialog mixPoolDialog = new MixPoolDialog(walletId, getWalletForm(), utxoEntries, premixPriority.getSelectedItem().getTx0FeeTarget()); + mixPool = mixPoolDialog.showDialog(SparrowTerminal.get().getGui()); + close(); + } + + private void onCancel() { + close(); + } + + @Override + public Pool showDialog(WindowBasedTextGUI textGUI) { + super.showDialog(textGUI); + return mixPool; + } + + private static class FeePriority { + private final String name; + private final Tx0FeeTarget tx0FeeTarget; + + public FeePriority(String name, Tx0FeeTarget tx0FeeTarget) { + this.name = name; + this.tx0FeeTarget = tx0FeeTarget; + } + + public Tx0FeeTarget getTx0FeeTarget() { + return tx0FeeTarget; + } + + @Override + public String toString() { + return name; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixPoolDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixPoolDialog.java new file mode 100644 index 00000000..74cb45d7 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixPoolDialog.java @@ -0,0 +1,240 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.samourai.whirlpool.client.tx0.Tx0Preview; +import com.samourai.whirlpool.client.tx0.Tx0Previews; +import com.samourai.whirlpool.client.wallet.beans.Tx0FeeTarget; +import com.samourai.whirlpool.client.whirlpool.beans.Pool; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.wallet.MixConfig; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.UnitFormat; +import com.sparrowwallet.sparrow.event.WalletMasterMixConfigChangedEvent; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; +import com.sparrowwallet.sparrow.wallet.WalletForm; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; + +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; + +public class MixPoolDialog extends WalletDialog { + private static final DisplayPool NULL_POOL = new DisplayPool(null); + + private final String walletId; + private final List utxoEntries; + private final Tx0FeeTarget tx0FeeTarget; + + private final ComboBox pool; + private final Label poolFeeLabel; + private final Label poolFee; + private final Label premixOutputs; + private final Button broadcast; + + private Tx0Previews tx0Previews; + private final ObjectProperty tx0PreviewProperty = new SimpleObjectProperty<>(null); + private Pool mixPool; + + public MixPoolDialog(String walletId, WalletForm walletForm, List utxoEntries, Tx0FeeTarget tx0FeeTarget) { + super(walletForm.getWallet().getFullDisplayName() + " Premix Pool", walletForm); + + this.walletId = walletId; + this.utxoEntries = utxoEntries; + this.tx0FeeTarget = tx0FeeTarget; + + setHints(List.of(Hint.CENTERED)); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(5).setVerticalSpacing(1)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new Label("Pool")); + pool = new ComboBox<>(); + pool.addItem(NULL_POOL); + pool.setEnabled(false); + mainPanel.addComponent(pool); + + poolFeeLabel = new Label("Pool fee"); + poolFeeLabel.setPreferredSize(new TerminalSize(21, 1)); + mainPanel.addComponent(poolFeeLabel); + poolFee = new Label(""); + mainPanel.addComponent(poolFee); + + mainPanel.addComponent(new Label("Premix outputs")); + premixOutputs = new Label(""); + mainPanel.addComponent(premixOutputs); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Cancel", this::onCancel)); + broadcast = new Button("Broadcast", this::onBroadcast).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false)); + buttonPanel.addComponent(broadcast); + broadcast.setEnabled(false); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + + pool.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + DisplayPool selectedPool = pool.getSelectedItem(); + if(selectedPool != NULL_POOL) { + UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); + poolFee.setText(format.formatSatsValue(selectedPool.pool.getFeeValue()) + " sats"); + fetchTx0Preview(selectedPool.pool); + } + }); + + tx0PreviewProperty.addListener((observable, oldValue, tx0Preview) -> { + SparrowTerminal.get().getGuiThread().invokeLater(() -> { + if(tx0Preview == null) { + premixOutputs.setText("Calculating..."); + broadcast.setEnabled(false); + } else { + if(tx0Preview.getPool().getFeeValue() != tx0Preview.getTx0Data().getFeeValue()) { + poolFeeLabel.setText("Pool fee (discounted)"); + } else { + poolFeeLabel.setText("Pool fee"); + } + + UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); + poolFee.setText(format.formatSatsValue(tx0Preview.getTx0Data().getFeeValue()) + " sats"); + premixOutputs.setText(tx0Preview.getNbPremix() + " UTXOs"); + broadcast.setEnabled(true); + } + }); + }); + + Platform.runLater(this::fetchPools); + } + + private void onBroadcast() { + mixPool = tx0PreviewProperty.get() == null ? null : tx0PreviewProperty.get().getPool(); + close(); + } + + private void onCancel() { + close(); + } + + @Override + public Pool showDialog(WindowBasedTextGUI textGUI) { + super.showDialog(textGUI); + return mixPool; + } + + private void fetchPools() { + long totalUtxoValue = utxoEntries.stream().mapToLong(Entry::getValue).sum(); + Whirlpool.PoolsService poolsService = new Whirlpool.PoolsService(AppServices.getWhirlpoolServices().getWhirlpool(walletId), totalUtxoValue); + poolsService.setOnSucceeded(workerStateEvent -> { + List availablePools = poolsService.getValue().stream().toList(); + if(availablePools.isEmpty()) { + SparrowTerminal.get().getGuiThread().invokeLater(() -> pool.setEnabled(false)); + + Whirlpool.PoolsService allPoolsService = new Whirlpool.PoolsService(AppServices.getWhirlpoolServices().getWhirlpool(walletId), null); + allPoolsService.setOnSucceeded(poolsStateEvent -> { + OptionalLong optMinValue = allPoolsService.getValue().stream().mapToLong(pool1 -> pool1.getPremixValueMin() + pool1.getFeeValue()).min(); + if(optMinValue.isPresent() && totalUtxoValue < optMinValue.getAsLong()) { + UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); + String satsValue = format.formatSatsValue(optMinValue.getAsLong()) + " sats"; + String btcValue = format.formatBtcValue(optMinValue.getAsLong()) + " BTC"; + AppServices.showErrorDialog("Insufficient UTXO Value", "No available pools. Select a value over " + (Config.get().getBitcoinUnit() == BitcoinUnit.BTC ? btcValue : satsValue) + "."); + SparrowTerminal.get().getGuiThread().invokeLater(this::close); + } + }); + allPoolsService.start(); + } else { + SparrowTerminal.get().getGuiThread().invokeLater(() -> { + pool.setEnabled(true); + pool.clearItems(); + availablePools.stream().map(DisplayPool::new).forEach(pool::addItem); + pool.setSelectedIndex(0); + }); + } + }); + poolsService.setOnFailed(workerStateEvent -> { + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + + Optional optButton = AppServices.showErrorDialog("Error fetching pools", exception.getMessage(), ButtonType.CANCEL, new ButtonType("Retry", ButtonBar.ButtonData.APPLY)); + if(optButton.isPresent()) { + if(optButton.get().getButtonData().equals(ButtonBar.ButtonData.APPLY)) { + fetchPools(); + } else { + SparrowTerminal.get().getGuiThread().invokeLater(() -> pool.setEnabled(false)); + } + } + }); + poolsService.start(); + } + + private void fetchTx0Preview(Pool pool) { + MixConfig mixConfig = getWalletForm().getWallet().getMasterMixConfig(); + if(mixConfig.getScode() == null) { + mixConfig.setScode(""); + EventManager.get().post(new WalletMasterMixConfigChangedEvent(getWalletForm().getWallet())); + } + + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId); + if(tx0Previews != null && mixConfig.getScode().equals(whirlpool.getScode()) && tx0FeeTarget == whirlpool.getTx0FeeTarget()) { + Tx0Preview tx0Preview = tx0Previews.getTx0Preview(pool.getPoolId()); + tx0PreviewProperty.set(tx0Preview); + } else { + tx0Previews = null; + whirlpool.setScode(mixConfig.getScode()); + whirlpool.setTx0FeeTarget(tx0FeeTarget); + + Whirlpool.Tx0PreviewsService tx0PreviewsService = new Whirlpool.Tx0PreviewsService(whirlpool, utxoEntries); + tx0PreviewsService.setOnRunning(workerStateEvent -> { + premixOutputs.setText("Calculating..."); + tx0PreviewProperty.set(null); + }); + tx0PreviewsService.setOnSucceeded(workerStateEvent -> { + tx0Previews = tx0PreviewsService.getValue(); + Tx0Preview tx0Preview = tx0Previews.getTx0Preview(pool.getPoolId()); + tx0PreviewProperty.set(tx0Preview); + }); + tx0PreviewsService.setOnFailed(workerStateEvent -> { + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + + AppServices.showErrorDialog("Error fetching Tx0","Error fetching Tx0: " + exception.getMessage()); + }); + tx0PreviewsService.start(); + } + } + + private static final class DisplayPool { + private final Pool pool; + + public DisplayPool(Pool pool) { + this.pool = pool; + } + + @Override + public String toString() { + if(pool == null) { + return "Fetching pools..."; + } + + UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); + return format.formatSatsValue(pool.getDenomination()) + " sats"; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java index c9b7fc86..da35f64c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java @@ -111,7 +111,15 @@ public class SettingsDialog extends WalletDialog { StandardAccount standardAccount = addAccountDialog.showDialog(SparrowTerminal.get().getGui()); if(standardAccount != null) { - addAccount(masterWallet, standardAccount); + addAccount(masterWallet, standardAccount, () -> { + SparrowTerminal.get().getGuiThread().invokeLater(() -> { + if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) { + showSuccessDialog("Added Accounts", "Whirlpool Accounts have been successfully added."); + } else { + showSuccessDialog("Added Account", standardAccount.getName() + " has been successfully added."); + } + }); + }); } } @@ -255,89 +263,6 @@ public class SettingsDialog extends WalletDialog { } } - private void addAccount(Wallet masterWallet, StandardAccount standardAccount) { - if(masterWallet.isEncrypted()) { - String walletId = getWalletForm().getWalletId(); - - TextInputDialogBuilder builder = new TextInputDialogBuilder().setTitle("Wallet Password"); - builder.setDescription("Enter the wallet password:"); - builder.setPasswordInput(true); - - String password = builder.build().showDialog(SparrowTerminal.get().getGui()); - if(password != null) { - Platform.runLater(() -> { - Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(getWalletForm().getStorage(), new SecureString(password), true); - keyDerivationService.setOnSucceeded(workerStateEvent -> { - EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done")); - ECKey encryptionFullKey = keyDerivationService.getValue(); - Key key = new Key(encryptionFullKey.getPrivKeyBytes(), getWalletForm().getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2); - encryptionFullKey.clear(); - masterWallet.decrypt(key); - addAndEncryptAccount(masterWallet, standardAccount, key); - }); - keyDerivationService.setOnFailed(workerStateEvent -> { - EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed")); - if(keyDerivationService.getException() instanceof InvalidPasswordException) { - showErrorDialog("Invalid Password", "The wallet password was invalid."); - } else { - log.error("Error deriving wallet key", keyDerivationService.getException()); - } - }); - EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet...")); - keyDerivationService.start(); - }); - } - } else { - Platform.runLater(() -> addAndSaveAccount(masterWallet, standardAccount, null)); - } - } - - private void addAndEncryptAccount(Wallet masterWallet, StandardAccount standardAccount, Key key) { - try { - addAndSaveAccount(masterWallet, standardAccount, key); - } finally { - masterWallet.encrypt(key); - key.clear(); - } - } - - private void addAndSaveAccount(Wallet masterWallet, StandardAccount standardAccount, Key key) { - List childWallets; - if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) { - childWallets = WhirlpoolServices.prepareWhirlpoolWallet(masterWallet, getWalletForm().getWalletId(), getWalletForm().getStorage()); - SparrowTerminal.get().getGuiThread().invokeLater(() -> showSuccessDialog("Added Accounts", "Whirlpool Accounts have been successfully added.")); - } else { - Wallet childWallet = masterWallet.addChildWallet(standardAccount); - EventManager.get().post(new ChildWalletsAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet)); - childWallets = List.of(childWallet); - SparrowTerminal.get().getGuiThread().invokeLater(() -> showSuccessDialog("Added Account", standardAccount.getName() + " has been successfully added.")); - } - - if(key != null) { - for(Wallet childWallet : childWallets) { - childWallet.encrypt(key); - } - } - - saveChildWallets(masterWallet); - } - - private void saveChildWallets(Wallet masterWallet) { - for(Wallet childWallet : masterWallet.getChildWallets()) { - if(!childWallet.isNested()) { - Storage storage = getWalletForm().getStorage(); - if(!storage.isPersisted(childWallet)) { - try { - storage.saveWallet(childWallet); - } catch(Exception e) { - log.error("Error saving wallet", e); - showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage()); - } - } - } - } - } - public static List splitString(String stringToSplit, int maxLength) { String text = stringToSplit; List lines = new ArrayList<>(); diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/UtxosDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/UtxosDialog.java index 039821aa..f4f067f7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/UtxosDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/UtxosDialog.java @@ -6,16 +6,19 @@ import com.googlecode.lanterna.gui2.*; import com.googlecode.lanterna.gui2.table.Table; import com.googlecode.lanterna.gui2.table.TableModel; import com.samourai.whirlpool.client.wallet.beans.MixProgress; -import com.sparrowwallet.drongo.wallet.MixConfig; -import com.sparrowwallet.drongo.wallet.StandardAccount; +import com.samourai.whirlpool.client.whirlpool.beans.Pool; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.net.ExchangeSource; +import com.sparrowwallet.sparrow.terminal.ModalDialog; import com.sparrowwallet.sparrow.terminal.SparrowTerminal; import com.sparrowwallet.sparrow.terminal.wallet.table.*; import com.sparrowwallet.sparrow.wallet.*; import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; @@ -23,6 +26,7 @@ import javafx.beans.value.WeakChangeListener; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; +import java.util.stream.Collectors; public class UtxosDialog extends WalletDialog { private final Label balance; @@ -34,6 +38,7 @@ public class UtxosDialog extends WalletDialog { private Button startMix; private Button mixTo; + private Button mixSelected; private final ChangeListener mixingOnlineListener = (observable, oldValue, newValue) -> { SparrowTerminal.get().getGuiThread().invokeLater(() -> startMix.setEnabled(newValue)); @@ -75,6 +80,8 @@ public class UtxosDialog extends WalletDialog { } } } + + startMix.setLabel(newValue ? "Stop Mixing" : "Start Mixing"); }; public UtxosDialog(WalletForm walletForm) { @@ -99,6 +106,13 @@ public class UtxosDialog extends WalletDialog { utxos = new Table<>(getTableColumns()); utxos.setTableCellRenderer(new EntryTableCellRenderer()); + utxos.setSelectAction(() -> { + if(utxos.getTableModel().getRowCount() > utxos.getSelectedRow()) { + TableCell dateCell = utxos.getTableModel().getRow(utxos.getSelectedRow()).get(0); + dateCell.setSelected(!dateCell.isSelected()); + updateMixSelectedButton(); + } + }); updateLabels(walletUtxosEntry); updateHistory(getWalletForm().getWalletUtxosEntry()); @@ -136,7 +150,14 @@ public class UtxosDialog extends WalletDialog { buttonPanel.addComponent(new Button("Back", () -> onBack(Function.UTXOS))); buttonPanel.addComponent(new Button("Refresh", this::onRefresh)); } else { - buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + if(WhirlpoolServices.canWalletMix(getWalletForm().getWallet())) { + mixSelected = new Button("Mix Selected", this::mixSelected); + mixSelected.setEnabled(false); + buttonPanel.addComponent(mixSelected); + } else { + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + } + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); buttonPanel.addComponent(new Button("Back", () -> onBack(Function.UTXOS))); @@ -306,6 +327,61 @@ public class UtxosDialog extends WalletDialog { } } + private void updateMixSelectedButton() { + if(mixSelected == null) { + return; + } + + mixSelected.setEnabled(!getSelectedEntries().isEmpty()); + } + + private List getSelectedEntries() { + return utxos.getTableModel().getRows().stream().map(row -> row.get(0)).filter(TableCell::isSelected).map(dateCell -> (UtxoEntry)dateCell.getEntry()).collect(Collectors.toList()); + } + + private void mixSelected() { + MixDialog mixDialog = new MixDialog(getWalletForm().getMasterWalletId(), getWalletForm(), getSelectedEntries()); + Pool pool = mixDialog.showDialog(SparrowTerminal.get().getGui()); + + if(pool != null) { + Wallet wallet = getWalletForm().getWallet(); + if(wallet.isMasterWallet() && !wallet.isWhirlpoolMasterWallet()) { + addAccount(wallet, StandardAccount.WHIRLPOOL_PREMIX, () -> broadcastPremix(pool)); + } else { + Platform.runLater(() -> broadcastPremix(pool)); + } + } + } + + public void broadcastPremix(Pool pool) { + ModalDialog broadcastingDialog = new ModalDialog(getWalletForm().getWallet().getFullDisplayName(), "Broadcasting premix..."); + SparrowTerminal.get().getGuiThread().invokeLater(() -> SparrowTerminal.get().getGui().addWindow(broadcastingDialog)); + + //The WhirlpoolWallet has already been configured + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getStorage().getWalletId(getWalletForm().getMasterWallet())); + List utxos = getSelectedEntries().stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList()); + Whirlpool.Tx0BroadcastService tx0BroadcastService = new Whirlpool.Tx0BroadcastService(whirlpool, pool, utxos); + tx0BroadcastService.setOnSucceeded(workerStateEvent -> { + Sha256Hash txid = tx0BroadcastService.getValue(); + SparrowTerminal.get().getGuiThread().invokeLater(() -> { + SparrowTerminal.get().getGui().removeWindow(broadcastingDialog); + AppServices.showSuccessDialog("Broadcast Successful", "Premix transaction id:\n" + txid.toString()); + }); + }); + tx0BroadcastService.setOnFailed(workerStateEvent -> { + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + String message = exception.getMessage(); + SparrowTerminal.get().getGuiThread().invokeLater(() -> { + SparrowTerminal.get().getGui().removeWindow(broadcastingDialog); + AppServices.showErrorDialog("Error broadcasting premix transaction", message); + }); + }); + tx0BroadcastService.start(); + } + @Subscribe public void walletNodesChanged(WalletNodesChangedEvent event) { if(event.getWallet().equals(getWalletForm().getWallet())) { diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletDialog.java index a7d2feee..89c24fe1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletDialog.java @@ -2,23 +2,42 @@ package com.sparrowwallet.sparrow.terminal.wallet; import com.google.common.base.Strings; import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.googlecode.lanterna.gui2.dialogs.TextInputDialogBuilder; import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.SecureString; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.EncryptionType; +import com.sparrowwallet.drongo.crypto.InvalidPasswordException; +import com.sparrowwallet.drongo.crypto.Key; import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.StandardAccount; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.CurrencyRate; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.UnitFormat; +import com.sparrowwallet.sparrow.event.ChildWalletsAddedEvent; +import com.sparrowwallet.sparrow.event.StorageEvent; +import com.sparrowwallet.sparrow.event.TimedEvent; import com.sparrowwallet.sparrow.event.WalletHistoryClearedEvent; import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.terminal.SparrowTerminal; import com.sparrowwallet.sparrow.wallet.Function; import com.sparrowwallet.sparrow.wallet.WalletForm; +import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices; import javafx.application.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Currency; +import java.util.List; + +import static com.sparrowwallet.sparrow.AppServices.showErrorDialog; public class WalletDialog extends DialogWindow { + private static final Logger log = LoggerFactory.getLogger(WalletDialog.class); + private final WalletForm walletForm; public WalletDialog(String title, WalletForm walletForm) { @@ -52,6 +71,95 @@ public class WalletDialog extends DialogWindow { } } + protected void addAccount(Wallet masterWallet, StandardAccount standardAccount, Runnable postAddition) { + if(masterWallet.isEncrypted()) { + String walletId = getWalletForm().getWalletId(); + + TextInputDialogBuilder builder = new TextInputDialogBuilder().setTitle("Wallet Password"); + builder.setDescription("Enter the wallet password:"); + builder.setPasswordInput(true); + + String password = builder.build().showDialog(SparrowTerminal.get().getGui()); + if(password != null) { + Platform.runLater(() -> { + Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(getWalletForm().getStorage(), new SecureString(password), true); + keyDerivationService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done")); + ECKey encryptionFullKey = keyDerivationService.getValue(); + Key key = new Key(encryptionFullKey.getPrivKeyBytes(), getWalletForm().getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2); + encryptionFullKey.clear(); + masterWallet.decrypt(key); + addAndEncryptAccount(masterWallet, standardAccount, key); + if(postAddition != null) { + postAddition.run(); + } + }); + keyDerivationService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed")); + if(keyDerivationService.getException() instanceof InvalidPasswordException) { + showErrorDialog("Invalid Password", "The wallet password was invalid."); + } else { + log.error("Error deriving wallet key", keyDerivationService.getException()); + } + }); + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet...")); + keyDerivationService.start(); + }); + } + } else { + Platform.runLater(() -> { + addAndSaveAccount(masterWallet, standardAccount, null); + if(postAddition != null) { + postAddition.run(); + } + }); + } + } + + private void addAndEncryptAccount(Wallet masterWallet, StandardAccount standardAccount, Key key) { + try { + addAndSaveAccount(masterWallet, standardAccount, key); + } finally { + masterWallet.encrypt(key); + key.clear(); + } + } + + private void addAndSaveAccount(Wallet masterWallet, StandardAccount standardAccount, Key key) { + List childWallets; + if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) { + childWallets = WhirlpoolServices.prepareWhirlpoolWallet(masterWallet, getWalletForm().getWalletId(), getWalletForm().getStorage()); + } else { + Wallet childWallet = masterWallet.addChildWallet(standardAccount); + EventManager.get().post(new ChildWalletsAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet)); + childWallets = List.of(childWallet); + } + + if(key != null) { + for(Wallet childWallet : childWallets) { + childWallet.encrypt(key); + } + } + + saveChildWallets(masterWallet); + } + + private void saveChildWallets(Wallet masterWallet) { + for(Wallet childWallet : masterWallet.getChildWallets()) { + if(!childWallet.isNested()) { + Storage storage = getWalletForm().getStorage(); + if(!storage.isPersisted(childWallet)) { + try { + storage.saveWallet(childWallet); + } catch(Exception e) { + log.error("Error saving wallet", e); + showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage()); + } + } + } + } + } + protected String formatBitcoinValue(long value, boolean appendUnit) { BitcoinUnit unit = Config.get().getBitcoinUnit(); if(unit == null || unit.equals(BitcoinUnit.AUTO)) { diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/DateTableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/DateTableCell.java index 917fac83..fe93ca52 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/DateTableCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/DateTableCell.java @@ -18,6 +18,16 @@ public class DateTableCell extends TableCell { @Override public String formatCell() { + String unselected = formatUnselectedCell(); + + if(selected) { + return "(*) " + unselected.substring(Math.min(4, unselected.length())); + } + + return unselected; + } + + public String formatUnselectedCell() { if(entry instanceof TransactionEntry transactionEntry && transactionEntry.getBlockTransaction() != null) { if(transactionEntry.getBlockTransaction().getHeight() == -1) { return "Unconfirmed Parent"; diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/TableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/TableCell.java index 6a9fce3a..037d9a05 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/TableCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/TableCell.java @@ -4,6 +4,7 @@ import com.sparrowwallet.sparrow.wallet.Entry; public abstract class TableCell { protected final Entry entry; + protected boolean selected; public TableCell(Entry entry) { this.entry = entry; @@ -14,4 +15,12 @@ public abstract class TableCell { } public abstract String formatCell(); + + public boolean isSelected() { + return selected; + } + + public void setSelected(boolean selected) { + this.selected = selected; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java index 90d4f88a..66b74fe3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java @@ -171,7 +171,7 @@ public class UtxoEntry extends HashIndexEntry { } public UtxoMixData getUtxoMixData() { - Wallet wallet = getUtxoEntry().getWallet().getMasterWallet(); + Wallet wallet = getUtxoEntry().getWallet().isMasterWallet() ? getUtxoEntry().getWallet() : getUtxoEntry().getWallet().getMasterWallet(); if(wallet.getUtxoMixData(getHashIndex()) != null) { return wallet.getUtxoMixData(getHashIndex()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java index 325c7f45..2460da24 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java @@ -480,6 +480,10 @@ public class Whirlpool { config.setScode(scode); } + public Tx0FeeTarget getTx0FeeTarget() { + return tx0FeeTarget; + } + public void setTx0FeeTarget(Tx0FeeTarget tx0FeeTarget) { this.tx0FeeTarget = tx0FeeTarget; }