From 9c3b647f0746fbe8d0978b6aa4112f638f1d5608 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Wed, 12 Jan 2022 15:44:13 +0200 Subject: [PATCH] add tool to sweep a private key in wif format to any address --- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 16 + .../sparrowwallet/sparrow/AppServices.java | 9 + .../control/BlockTargetFeeRatesChart.java | 4 +- .../control/MempoolSizeFeeRatesChart.java | 4 +- .../sparrow/control/MessageSignDialog.java | 2 + .../control/PrivateKeySweepDialog.java | 346 ++++++++++++++++++ .../sparrow/glyphfont/FontAwesome5.java | 1 + .../sparrow/net/ElectrumServer.java | 73 +++- .../sparrow/wallet/SendController.java | 8 +- .../com/sparrowwallet/sparrow/app.fxml | 1 + .../com/sparrowwallet/sparrow/dialog.css | 10 + 12 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java create mode 100644 src/main/resources/com/sparrowwallet/sparrow/dialog.css diff --git a/drongo b/drongo index 3a557e3a..34bd72d8 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 3a557e3af8bd17abf7697f93e586baf67745b460 +Subproject commit 34bd72d87aac7286fd0ca7e94f5a931f00d13cb4 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index a068c7f5..c32df546 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -161,6 +161,9 @@ public class AppController implements Initializable { @FXML private MenuItem sendToMany; + @FXML + private MenuItem sweepPrivateKey; + @FXML private MenuItem findMixingPartner; @@ -333,6 +336,7 @@ public class AppController implements Initializable { lockWallet.setDisable(true); refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not()))); sendToMany.disableProperty().bind(exportWallet.disableProperty()); + sweepPrivateKey.disableProperty().bind(Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())); showPayNym.disableProperty().bind(findMixingPartner.disableProperty()); findMixingPartner.setDisable(true); AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { @@ -1230,6 +1234,18 @@ public class AppController implements Initializable { } } + public void sweepPrivateKey(ActionEvent event) { + Wallet wallet = null; + WalletForm selectedWalletForm = getSelectedWalletForm(); + if(selectedWalletForm != null) { + wallet = selectedWalletForm.getWallet(); + } + + PrivateKeySweepDialog dialog = new PrivateKeySweepDialog(wallet); + Optional optTransaction = dialog.showAndWait(); + optTransaction.ifPresent(transaction -> addTransactionTab(null, null, transaction)); + } + public void findMixingPartner(ActionEvent event) { WalletForm selectedWalletForm = getSelectedWalletForm(); if(selectedWalletForm != null) { diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 7fe353ac..ff0f91fa 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -76,6 +76,10 @@ public class AppServices { private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD"); private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default"; + public static final List TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50); + public static final List FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L); + public static final double FALLBACK_FEE_RATE = 20000d / 1000; + private static AppServices INSTANCE; private final WhirlpoolServices whirlpoolServices = new WhirlpoolServices(); @@ -591,6 +595,11 @@ public class AppServices { return latestBlockHeader; } + public static Double getDefaultFeeRate() { + int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); + return getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getTargetBlockFeeRates().get(defaultTarget); + } + public static Map getTargetBlockFeeRates() { return targetBlockFeeRates; } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/BlockTargetFeeRatesChart.java b/src/main/java/com/sparrowwallet/sparrow/control/BlockTargetFeeRatesChart.java index af27fc57..4e9c88a2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/BlockTargetFeeRatesChart.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/BlockTargetFeeRatesChart.java @@ -1,6 +1,6 @@ package com.sparrowwallet.sparrow.control; -import com.sparrowwallet.sparrow.wallet.SendController; +import com.sparrowwallet.sparrow.AppServices; import javafx.beans.NamedArg; import javafx.scene.Node; import javafx.scene.chart.Axis; @@ -28,7 +28,7 @@ public class BlockTargetFeeRatesChart extends LineChart { for(Iterator targetBlocksIter = targetBlocksFeeRates.keySet().iterator(); targetBlocksIter.hasNext(); ) { Integer targetBlocks = targetBlocksIter.next(); - if(SendController.TARGET_BLOCKS_RANGE.contains(targetBlocks)) { + if(AppServices.TARGET_BLOCKS_RANGE.contains(targetBlocks)) { String category = targetBlocks + (targetBlocksIter.hasNext() ? "" : "+"); XYChart.Data data = new XYChart.Data<>(category, targetBlocksFeeRates.get(targetBlocks)); feeRateSeries.getData().add(data); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java b/src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java index 90961a63..4c5f76e3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java @@ -1,8 +1,8 @@ package com.sparrowwallet.sparrow.control; +import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.net.MempoolRateSize; -import com.sparrowwallet.sparrow.wallet.SendController; import javafx.application.Platform; import javafx.beans.NamedArg; import javafx.collections.FXCollections; @@ -79,7 +79,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart { }); long previousFeeRate = 0; - for(Long feeRate : SendController.FEE_RATES_RANGE) { + for(Long feeRate : AppServices.FEE_RATES_RANGE) { XYChart.Series series = new XYChart.Series<>(); series.setName(feeRate + "+ sats/vB"); long seriesTotalVSize = 0; diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java index 6d91b9a2..63a6a12f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java @@ -103,6 +103,7 @@ public class MessageSignDialog extends Dialog { final DialogPane dialogPane = getDialogPane(); dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm()); AppServices.setStageIcon(dialogPane.getScene().getWindow()); dialogPane.setHeaderText(title == null ? (wallet == null ? "Verify Message" : "Sign/Verify Message") : title); @@ -120,6 +121,7 @@ public class MessageSignDialog extends Dialog { Form form = new Form(); Fieldset fieldset = new Fieldset(); fieldset.setText(""); + fieldset.setSpacing(10); Field addressField = new Field(); addressField.setText("Address:"); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java new file mode 100644 index 00000000..afc31000 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java @@ -0,0 +1,346 @@ +package com.sparrowwallet.sparrow.control; + +import com.google.common.io.Files; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.crypto.DumpedPrivateKey; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.psbt.PSBT; +import com.sparrowwallet.drongo.psbt.PSBTInput; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.net.ElectrumServer; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.event.ActionEvent; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import javafx.util.StringConverter; +import org.controlsfx.glyphfont.Glyph; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.decoration.StyleClassValidationDecoration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tornadofx.control.Field; +import tornadofx.control.Fieldset; +import tornadofx.control.Form; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR; + +public class PrivateKeySweepDialog extends Dialog { + private static final Logger log = LoggerFactory.getLogger(PrivateKeySweepDialog.class); + + private final TextArea key; + private final ComboBox keyScriptType; + private final CopyableLabel keyAddress; + private final ComboBoxTextField toAddress; + private final ComboBox toWallet; + + public PrivateKeySweepDialog(Wallet wallet) { + final DialogPane dialogPane = getDialogPane(); + dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm()); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + dialogPane.setHeaderText("Sweep Private Key"); + + Image image = new Image("image/seed.png", 50, 50, false, false); + if(!image.isError()) { + ImageView imageView = new ImageView(); + imageView.setSmooth(false); + imageView.setImage(image); + dialogPane.setGraphic(imageView); + } + + VBox vBox = new VBox(); + vBox.setSpacing(20); + + Form form = new Form(); + Fieldset fieldset = new Fieldset(); + fieldset.setText(""); + fieldset.setSpacing(10); + + Field keyField = new Field(); + keyField.setText("Private Key:"); + key = new TextArea(); + key.setWrapText(true); + key.setPromptText("Wallet Import Format (WIF)"); + key.setPrefRowCount(2); + key.getStyleClass().add("fixed-width"); + HBox keyBox = new HBox(5); + VBox keyButtonBox = new VBox(5); + Button scanKey = new Button("", getGlyph(FontAwesome5.Glyph.CAMERA)); + scanKey.setOnAction(event -> scanPrivateKey()); + Button readKey = new Button("", getGlyph(FontAwesome5.Glyph.FILE_IMPORT)); + readKey.setOnAction(event -> readPrivateKey()); + keyButtonBox.getChildren().addAll(scanKey, readKey); + keyBox.getChildren().addAll(key, keyButtonBox); + HBox.setHgrow(key, Priority.ALWAYS); + keyField.getInputs().add(keyBox); + + Field keyScriptTypeField = new Field(); + keyScriptTypeField.setText("Script Type:"); + keyScriptType = new ComboBox<>(); + keyScriptType.setItems(FXCollections.observableList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE))); + keyScriptTypeField.getInputs().add(keyScriptType); + + keyScriptType.setConverter(new StringConverter() { + @Override + public String toString(ScriptType scriptType) { + return scriptType == null ? "" : scriptType.getDescription(); + } + + @Override + public ScriptType fromString(String string) { + return null; + } + }); + + Field addressField = new Field(); + addressField.setText("Address:"); + keyAddress = new CopyableLabel(); + keyAddress.getStyleClass().add("fixed-width"); + addressField.getInputs().add(keyAddress); + + Field toAddressField = new Field(); + toAddressField.setText("Sweep to:"); + toAddress = new ComboBoxTextField(); + toAddress.getStyleClass().add("fixed-width"); + toWallet = new ComboBox<>(); + toWallet.setItems(FXCollections.observableList(new ArrayList<>(AppServices.get().getOpenWallets().keySet()))); + toAddress.setComboProperty(toWallet); + toWallet.prefWidthProperty().bind(toAddress.widthProperty()); + StackPane stackPane = new StackPane(); + stackPane.getChildren().addAll(toWallet, toAddress); + toAddressField.getInputs().add(stackPane); + + fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField); + form.getChildren().add(fieldset); + dialogPane.setContent(form); + + ButtonType createButtonType = new javafx.scene.control.ButtonType("Create Transaction", ButtonBar.ButtonData.APPLY); + ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + + dialogPane.getButtonTypes().addAll(cancelButtonType, createButtonType); + + Button createButton = (Button) dialogPane.lookupButton(createButtonType); + createButton.setDefaultButton(true); + createButton.setDisable(true); + createButton.addEventFilter(ActionEvent.ACTION, event -> { + createTransaction(); + event.consume(); + }); + + key.textProperty().addListener((observable, oldValue, newValue) -> { + boolean isValidKey = isValidKey(); + createButton.setDisable(!isValidKey || !isValidToAddress()); + if(isValidKey) { + setFromAddress(); + } + }); + + keyScriptType.valueProperty().addListener((observable, oldValue, newValue) -> { + if(isValidKey()) { + setFromAddress(); + } + }); + + toAddress.textProperty().addListener((observable, oldValue, newValue) -> { + createButton.setDisable(!isValidKey() || !isValidToAddress()); + }); + + toWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> { + if(selectedWallet != null) { + toAddress.setText(selectedWallet.getAddress(selectedWallet.getFreshNode(KeyPurpose.RECEIVE)).toString()); + } + }); + + keyScriptType.setValue(ScriptType.P2PKH); + if(wallet != null) { + toAddress.setText(wallet.getAddress(wallet.getFreshNode(KeyPurpose.RECEIVE)).toString()); + } + + AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(null)); + AppServices.moveToActiveWindowScreen(this); + setResultConverter(dialogButton -> null); + dialogPane.setPrefWidth(680); + + ValidationSupport validationSupport = new ValidationSupport(); + Platform.runLater(() -> { + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + validationSupport.registerValidator(key, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid private Key", !key.getText().isEmpty() && !isValidKey())); + validationSupport.registerValidator(toAddress, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !toAddress.getText().isEmpty() && !isValidToAddress())); + }); + } + + private boolean isValidKey() { + try { + DumpedPrivateKey privateKey = getPrivateKey(); + return true; + } catch(Exception e) { + return false; + } + } + + private DumpedPrivateKey getPrivateKey() { + return DumpedPrivateKey.fromBase58(key.getText()); + } + + private boolean isValidToAddress() { + try { + Address address = getToAddress(); + return true; + } catch (InvalidAddressException e) { + return false; + } + } + + private Address getToAddress() throws InvalidAddressException { + return Address.fromString(toAddress.getText()); + } + + private void setFromAddress() { + DumpedPrivateKey privateKey = getPrivateKey(); + ScriptType scriptType = keyScriptType.getValue(); + Address address = scriptType.getAddress(privateKey.getKey()); + keyAddress.setText(address.toString()); + } + + private void scanPrivateKey() { + QRScanDialog qrScanDialog = new QRScanDialog(); + Optional result = qrScanDialog.showAndWait(); + if(result.isPresent() && result.get().payload != null) { + key.setText(result.get().payload); + } + } + + private void readPrivateKey() { + Stage window = new Stage(); + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Open Private Key File"); + + AppServices.moveToActiveWindowScreen(window, 800, 450); + File file = fileChooser.showOpenDialog(window); + if(file != null) { + if(file.length() > 1024) { + AppServices.showErrorDialog("Invalid private key file", "This file does not contain a valid private key."); + return; + } + + try { + key.setText(Files.asCharSource(file, StandardCharsets.UTF_8).read().trim()); + } catch(IOException e) { + AppServices.showErrorDialog("Error reading private key file", e.getMessage()); + } + } + } + + private void createTransaction() { + try { + DumpedPrivateKey privateKey = getPrivateKey(); + ScriptType scriptType = keyScriptType.getValue(); + Address fromAddress = scriptType.getAddress(privateKey.getKey()); + Address destAddress = getToAddress(); + + ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress); + addressUtxosService.setOnSucceeded(successEvent -> { + createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress); + }); + addressUtxosService.setOnFailed(failedEvent -> { + log.error("Error retrieving outputs for address " + fromAddress, failedEvent.getSource().getException()); + AppServices.showErrorDialog("Error retrieving outputs for address", failedEvent.getSource().getException().getMessage()); + }); + addressUtxosService.start(); + } catch(Exception e) { + log.error("Error creating sweep transaction", e); + } + } + + private void createTransaction(ECKey privKey, ScriptType scriptType, List txOutputs, Address destAddress) { + ECKey pubKey = ECKey.fromPublicOnly(privKey); + + Transaction noFeeTransaction = new Transaction(); + long total = 0; + for(TransactionOutput txOutput : txOutputs) { + scriptType.addSpendingInput(noFeeTransaction, txOutput, pubKey, TransactionSignature.dummy(scriptType == P2TR ? TransactionSignature.Type.SCHNORR : TransactionSignature.Type.ECDSA)); + total += txOutput.getValue(); + } + + TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript()); + noFeeTransaction.addOutput(sweepOutput); + + Double feeRate = AppServices.getDefaultFeeRate(); + long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate); + if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) { + fee++; + } + + long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE); + if(total - fee <= dustThreshold) { + AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats)."); + return; + } + + Transaction transaction = new Transaction(); + transaction.setVersion(2); + transaction.setLocktime(AppServices.getCurrentBlockHeight() == null ? 0 : AppServices.getCurrentBlockHeight()); + for(TransactionInput txInput : noFeeTransaction.getInputs()) { + transaction.addInput(txInput); + } + transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript())); + + PSBT psbt = new PSBT(transaction); + for(int i = 0; i < txOutputs.size(); i++) { + TransactionOutput utxoOutput = txOutputs.get(i); + TransactionInput txInput = transaction.getInputs().get(i); + PSBTInput psbtInput = psbt.getPsbtInputs().get(i); + psbtInput.setWitnessUtxo(utxoOutput); + + if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) { + psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript()); + } + + if(txInput.getWitness() != null) { + psbtInput.setWitnessScript(txInput.getWitness().getWitnessScript()); + } + + if(!psbtInput.sign(scriptType.getOutputKey(privKey))) { + AppServices.showErrorDialog("Failed to sign", "Failed to sign for transaction output " + utxoOutput.getHash() + ":" + utxoOutput.getIndex()); + return; + } + + TransactionSignature signature = psbtInput.isTaproot() ? psbtInput.getTapKeyPathSignature() : psbtInput.getPartialSignature(pubKey); + + Transaction finalizeTransaction = new Transaction(); + TransactionInput finalizedTxInput = scriptType.addSpendingInput(finalizeTransaction, utxoOutput, pubKey, signature); + psbtInput.setFinalScriptSig(finalizedTxInput.getScriptSig()); + psbtInput.setFinalScriptWitness(finalizedTxInput.getWitness()); + } + + setResult(psbt.extractTransaction()); + } + + public Glyph getGlyph(FontAwesome5.Glyph glyphEnum) { + Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphEnum); + glyph.setFontSize(12); + return glyph; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index cb0f0238..45311083 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -37,6 +37,7 @@ public class FontAwesome5 extends GlyphFont { EYE('\uf06e'), FEATHER_ALT('\uf56b'), FILE_CSV('\uf6dd'), + FILE_IMPORT('\uf56f'), HAND_HOLDING('\uf4bd'), HAND_HOLDING_MEDICAL('\ue05c'), HAND_HOLDING_WATER('\uf4c1'), diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 577d0da1..14b6601f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -6,13 +6,13 @@ import com.google.common.net.HostAndPort; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Config; -import com.sparrowwallet.sparrow.wallet.SendController; import javafx.application.Platform; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; @@ -847,6 +847,47 @@ public class ElectrumServer { return mempoolScriptHashes; } + public List getUtxos(Address address) throws ServerException { + Wallet wallet = new Wallet(address.toString()); + Map pathScriptHashes = new HashMap<>(); + pathScriptHashes.put("m/0", getScriptHash(address)); + Map historyResult = electrumServerRpc.getScriptHashHistory(getTransport(), wallet, pathScriptHashes, true); + Set txids = Arrays.stream(historyResult.get("m/0")).map(scriptHashTx -> scriptHashTx.tx_hash).collect(Collectors.toSet()); + + Map transactionsResult = electrumServerRpc.getTransactions(getTransport(), wallet, txids); + List transactionOutputs = new ArrayList<>(); + Script outputScript = address.getOutputScript(); + String strErrorTx = Sha256Hash.ZERO_HASH.toString(); + List transactions = new ArrayList<>(); + for(String txid : transactionsResult.keySet()) { + String strRawTx = transactionsResult.get(txid); + + if(strRawTx.equals(strErrorTx)) { + continue; + } + + try { + Transaction transaction = new Transaction(Utils.hexToBytes(strRawTx)); + for(TransactionOutput txOutput : transaction.getOutputs()) { + if(txOutput.getScript().equals(outputScript)) { + transactionOutputs.add(txOutput); + } + } + transactions.add(transaction); + } catch(ProtocolException e) { + log.error("Could not parse tx: " + strRawTx); + } + } + + for(Transaction transaction : transactions) { + for(TransactionInput txInput : transaction.getInputs()) { + transactionOutputs.removeIf(txOutput -> txOutput.getHash().equals(txInput.getOutpoint().getHash()) && txOutput.getIndex() == txInput.getOutpoint().getIndex()); + } + } + + return transactionOutputs; + } + public static Map getAllScriptHashes(Wallet wallet) { Map scriptHashes = new HashMap<>(); for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { @@ -870,6 +911,12 @@ public class ElectrumServer { return Utils.bytesToHex(reversed); } + public static String getScriptHash(Address address) { + byte[] hash = Sha256Hash.hash(address.getOutputScript().getProgram()); + byte[] reversed = Utils.reverseBytes(hash); + return Utils.bytesToHex(reversed); + } + public static Map> getSubscribedScriptHashes() { return subscribedScriptHashes; } @@ -1038,7 +1085,7 @@ public class ElectrumServer { String banner = electrumServer.getServerBanner(); - Map blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE); + Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE); Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); feeRatesRetrievedAt = System.currentTimeMillis(); @@ -1054,7 +1101,7 @@ public class ElectrumServer { long elapsed = System.currentTimeMillis() - feeRatesRetrievedAt; if(elapsed > FEE_RATES_PERIOD) { - Map blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE); + Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE); Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); feeRatesRetrievedAt = System.currentTimeMillis(); return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes); @@ -1430,7 +1477,7 @@ public class ElectrumServer { return new Task<>() { protected FeeRatesUpdatedEvent call() throws ServerException { ElectrumServer electrumServer = new ElectrumServer(); - Map blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE); + Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE); Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes); } @@ -1481,4 +1528,22 @@ public class ElectrumServer { }; } } + + public static class AddressUtxosService extends Service> { + private final Address address; + + public AddressUtxosService(Address address) { + this.address = address; + } + + @Override + protected Task> createTask() { + return new Task<>() { + protected List call() throws ServerException { + ElectrumServer electrumServer = new ElectrumServer(); + return electrumServer.getUtxos(address); + } + }; + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index 519a8534..5b7715b9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -4,7 +4,6 @@ import com.google.common.eventbus.Subscribe; import com.samourai.whirlpool.client.whirlpool.beans.Pool; import com.sparrowwallet.drongo.BitcoinUnit; import com.sparrowwallet.drongo.KeyPurpose; -import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; @@ -60,14 +59,11 @@ import java.text.DecimalFormatSymbols; import java.util.*; import java.util.stream.Collectors; +import static com.sparrowwallet.sparrow.AppServices.*; + public class SendController extends WalletFormController implements Initializable { private static final Logger log = LoggerFactory.getLogger(SendController.class); - public static final List TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50); - public static final List FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L); - - public static final double FALLBACK_FEE_RATE = 20000d / 1000; - @FXML private TabPane paymentTabs; diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index 1d9037bc..0dfdaaa4 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -110,6 +110,7 @@ + diff --git a/src/main/resources/com/sparrowwallet/sparrow/dialog.css b/src/main/resources/com/sparrowwallet/sparrow/dialog.css new file mode 100644 index 00000000..928ab1b3 --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/dialog.css @@ -0,0 +1,10 @@ +.root.dialog-pane .header-panel { + -fx-background-color: -fx-control-inner-background; + -fx-border-width: 0px 0px 1px 0px; + -fx-border-color: #e5e5e6; +} + +.header-panel .label { + -fx-font-size: 24px; +} +