Craig Raw
3 years ago
12 changed files with 461 additions and 15 deletions
@ -1 +1 @@ |
|||||
Subproject commit 3a557e3af8bd17abf7697f93e586baf67745b460 |
Subproject commit 34bd72d87aac7286fd0ca7e94f5a931f00d13cb4 |
@ -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<Transaction> { |
||||
|
private static final Logger log = LoggerFactory.getLogger(PrivateKeySweepDialog.class); |
||||
|
|
||||
|
private final TextArea key; |
||||
|
private final ComboBox<ScriptType> keyScriptType; |
||||
|
private final CopyableLabel keyAddress; |
||||
|
private final ComboBoxTextField toAddress; |
||||
|
private final ComboBox<Wallet> 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<ScriptType>() { |
||||
|
@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<QRScanDialog.Result> 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<TransactionOutput> 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; |
||||
|
} |
||||
|
} |
@ -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; |
||||
|
} |
||||
|
|
Loading…
Reference in new issue