From b1b47340949d3285b93cf1995fa6dd8775b6820a Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Sun, 14 Jun 2020 10:36:24 +0200 Subject: [PATCH] fetch transaction output transactions, handle coinbase input --- .../sparrowwallet/sparrow/AppController.java | 35 +++- .../sparrow/control/CoinLabel.java | 13 +- .../sparrow/control/TransactionIdDialog.java | 65 ++++++++ .../BlockTransactionOutputsFetchedEvent.java | 24 +++ .../sparrow/io/ElectrumServer.java | 150 +++++++++++++++++- .../transaction/HeadersController.java | 6 + .../sparrow/transaction/InputController.java | 11 +- .../sparrow/transaction/InputsController.java | 23 ++- .../sparrow/transaction/OutputController.java | 76 ++++++++- .../sparrow/transaction/OutputForm.java | 6 +- .../transaction/TransactionController.java | 72 ++++++--- .../sparrow/transaction/TransactionForm.java | 10 ++ .../TransactionFormController.java | 29 +++- .../com/sparrowwallet/sparrow/app.fxml | 1 + .../sparrow/transaction/input.fxml | 2 +- .../sparrow/transaction/output.css | 10 +- .../sparrow/transaction/output.fxml | 6 + 17 files changed, 491 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/TransactionIdDialog.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/event/BlockTransactionOutputsFetchedEvent.java diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 418e3b0f..9d99e0b0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -9,6 +9,7 @@ import com.sparrowwallet.drongo.crypto.InvalidPasswordException; import com.sparrowwallet.drongo.crypto.Key; import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBTParseException; @@ -23,6 +24,7 @@ import com.sparrowwallet.sparrow.wallet.WalletController; import com.sparrowwallet.sparrow.wallet.WalletForm; import de.codecentric.centerdevice.MenuToolkit; import javafx.animation.*; +import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.concurrent.Worker; @@ -57,6 +59,9 @@ public class AppController implements Initializable { @FXML private Menu fileMenu; + @FXML + private MenuItem openTransactionIdItem; + @FXML private CheckMenuItem showTxHex; @@ -166,6 +171,8 @@ public class AppController implements Initializable { connectionService.start(); } + openTransactionIdItem.disableProperty().bind(onlineProperty.not()); + openWalletFile(new File("/Users/scy/.sparrow/wallets/sparta.json")); } @@ -294,6 +301,32 @@ public class AppController implements Initializable { } } + public void openTransactionFromId(ActionEvent event) { + TransactionIdDialog dialog = new TransactionIdDialog(); + Optional optionalTxId = dialog.showAndWait(); + if(optionalTxId.isPresent()) { + Sha256Hash txId = optionalTxId.get(); + ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txId)); + transactionReferenceService.setOnSucceeded(successEvent -> { + BlockTransaction blockTransaction = transactionReferenceService.getValue().get(txId); + if(blockTransaction == null) { + showErrorDialog("Invalid transaction ID", "A transaction with that ID could not be found."); + } else { + Platform.runLater(() -> { + EventManager.get().post(new ViewTransactionEvent(blockTransaction)); + }); + } + }); + transactionReferenceService.setOnFailed(failEvent -> { + Platform.runLater(() -> { + Throwable e = failEvent.getSource().getException(); + showErrorDialog("Error fetching transaction", e.getCause() != null ? e.getCause().getMessage() : e.getMessage()); + }); + }); + transactionReferenceService.start(); + } + } + public static boolean isOnline() { return onlineProperty.get(); } @@ -564,7 +597,7 @@ public class AppController implements Initializable { TabData tabData = (TabData)tab.getUserData(); if(tabData instanceof TransactionTabData) { TransactionTabData transactionTabData = (TransactionTabData)tabData; - if(transactionTabData.getTransaction() == transaction) { + if(transactionTabData.getTransaction().getTxId().equals(transaction.getTxId())) { return tab; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CoinLabel.java b/src/main/java/com/sparrowwallet/sparrow/control/CoinLabel.java index 1c5f7ebd..0170b1d8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/CoinLabel.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/CoinLabel.java @@ -18,9 +18,9 @@ public class CoinLabel extends CopyableLabel { public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); - private final LongProperty value = new SimpleLongProperty(); - private Tooltip tooltip; - private CoinContextMenu contextMenu; + private final LongProperty value = new SimpleLongProperty(-1); + private final Tooltip tooltip; + private final CoinContextMenu contextMenu; public CoinLabel() { this("Unknown"); @@ -62,11 +62,8 @@ public class CoinLabel extends CopyableLabel { } private class CoinContextMenu extends ContextMenu { - private MenuItem copySatsValue; - private MenuItem copyBtcValue; - public CoinContextMenu() { - copySatsValue = new MenuItem("Copy Value in Satoshis"); + MenuItem copySatsValue = new MenuItem("Copy Value in Satoshis"); copySatsValue.setOnAction(AE -> { hide(); ClipboardContent content = new ClipboardContent(); @@ -74,7 +71,7 @@ public class CoinLabel extends CopyableLabel { Clipboard.getSystemClipboard().setContent(content); }); - copyBtcValue = new MenuItem("Copy Value in BTC"); + MenuItem copyBtcValue = new MenuItem("Copy Value in BTC"); copyBtcValue.setOnAction(AE -> { hide(); ClipboardContent content = new ClipboardContent(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionIdDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionIdDialog.java new file mode 100644 index 00000000..0ae296f2 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionIdDialog.java @@ -0,0 +1,65 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.sparrow.AppController; +import com.sparrowwallet.sparrow.io.Storage; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.scene.control.*; +import javafx.scene.layout.VBox; +import org.controlsfx.control.textfield.CustomTextField; +import org.controlsfx.control.textfield.TextFields; +import org.controlsfx.glyphfont.FontAwesome; +import org.controlsfx.glyphfont.Glyph; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.Validator; +import org.controlsfx.validation.decoration.StyleClassValidationDecoration; + +public class TransactionIdDialog extends Dialog { + private final CustomTextField txid; + + public TransactionIdDialog() { + this.txid = (CustomTextField) TextFields.createClearableTextField(); + final DialogPane dialogPane = getDialogPane(); + + setTitle("Load Transaction"); + dialogPane.setHeaderText("Enter the transaction ID:"); + dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm()); + dialogPane.getButtonTypes().addAll(ButtonType.CANCEL); + dialogPane.setPrefWidth(380); + dialogPane.setPrefHeight(200); + + Glyph wallet = new Glyph("FontAwesome", FontAwesome.Glyph.BITCOIN); + wallet.setFontSize(50); + dialogPane.setGraphic(wallet); + + final VBox content = new VBox(10); + content.getChildren().add(txid); + + dialogPane.setContent(content); + + ValidationSupport validationSupport = new ValidationSupport(); + Platform.runLater(() -> { + validationSupport.registerValidator(txid, Validator.combine( + Validator.createEmptyValidator("Transaction id is required"), + (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID length incorrect", newValue.length() != 64), + (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID must be hexadecimal", !Utils.isHex(newValue)) + )); + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + }); + + final ButtonType okButtonType = new javafx.scene.control.ButtonType("Open Transaction", ButtonBar.ButtonData.OK_DONE); + dialogPane.getButtonTypes().addAll(okButtonType); + Button okButton = (Button) dialogPane.lookupButton(okButtonType); + BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> + txid.getText().length() != 64 || !Utils.isHex(txid.getText()), txid.textProperty()); + okButton.disableProperty().bind(isInvalid); + + txid.setPromptText("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"); + txid.requestFocus(); + setResultConverter(dialogButton -> dialogButton == okButtonType ? Sha256Hash.wrap(txid.getText()) : null); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/BlockTransactionOutputsFetchedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/BlockTransactionOutputsFetchedEvent.java new file mode 100644 index 00000000..eee175ff --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/BlockTransactionOutputsFetchedEvent.java @@ -0,0 +1,24 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.wallet.BlockTransaction; + +import java.util.List; + +public class BlockTransactionOutputsFetchedEvent { + private final Sha256Hash txId; + private final List outputTransactions; + + public BlockTransactionOutputsFetchedEvent(Sha256Hash txId, List outputTransactions) { + this.txId = txId; + this.outputTransactions = outputTransactions; + } + + public Sha256Hash getTxId() { + return txId; + } + + public List getOutputTransactions() { + return outputTransactions; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java index a3053bc8..d6b94e4a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java @@ -41,6 +41,8 @@ import java.util.stream.Collectors; public class ElectrumServer { private static final String[] SUPPORTED_VERSIONS = new String[]{"1.3", "1.4.2"}; + public static final BlockTransaction UNFETCHABLE_BLOCK_TRANSACTION = new BlockTransaction(Sha256Hash.ZERO_HASH, 0, null, null, null); + private static Transport transport; private static synchronized Transport getTransport() throws ServerException { @@ -134,7 +136,8 @@ public class ElectrumServer { public void getHistory(Wallet wallet, KeyPurpose keyPurpose, Map> nodeTransactionMap) throws ServerException { getHistory(wallet, wallet.getNode(keyPurpose).getChildren(), nodeTransactionMap); - getMempool(wallet, wallet.getNode(keyPurpose).getChildren(), nodeTransactionMap); + //Not necessary, mempool transactions included in history + //getMempool(wallet, wallet.getNode(keyPurpose).getChildren(), nodeTransactionMap); } public void getHistory(Wallet wallet, Collection nodes, Map> nodeTransactionMap) throws ServerException { @@ -189,6 +192,47 @@ public class ElectrumServer { } } + @SuppressWarnings("unchecked") + public List> getOutputTransactionReferences(Transaction transaction) throws ServerException { + try { + JsonRpcClient client = new JsonRpcClient(getTransport()); + BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(ScriptHashTx[].class); + for(int i = 0; i < transaction.getOutputs().size(); i++) { + TransactionOutput output = transaction.getOutputs().get(i); + batchRequest.add(i, "blockchain.scripthash.get_history", getScriptHash(output)); + } + + Map result; + try { + result = batchRequest.execute(); + } catch (JsonRpcBatchException e) { + result = (Map)e.getSuccesses(); + for(Object index : e.getErrors().keySet()) { + Integer i = (Integer)index; + result.put(i, new ScriptHashTx[] {ScriptHashTx.ERROR_TX}); + } + } + + List> blockTransactionHashes = new ArrayList<>(transaction.getOutputs().size()); + for(int i = 0; i < transaction.getOutputs().size(); i++) { + blockTransactionHashes.add(null); + } + + for(Integer index : result.keySet()) { + ScriptHashTx[] txes = result.get(index); + + Set references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash).filter(ref -> !ref.getHash().equals(transaction.getTxId())).collect(Collectors.toCollection(TreeSet::new)); + blockTransactionHashes.set(index, references); + } + + return blockTransactionHashes; + } catch (IllegalStateException e) { + throw new ServerException(e.getCause()); + } catch (Exception e) { + throw new ServerException(e); + } + } + public void getReferencedTransactions(Wallet wallet, Map> nodeTransactionMap) throws ServerException { Set references = new TreeSet<>(); for(Set nodeReferences : nodeTransactionMap.values()) { @@ -209,6 +253,7 @@ public class ElectrumServer { } } + @SuppressWarnings("unchecked") public Map getBlockHeaders(Set references) throws ServerException { try { Set blockHeights = new TreeSet<>(); @@ -221,7 +266,13 @@ public class ElectrumServer { for(Integer height : blockHeights) { batchRequest.add(height, "blockchain.block.header", height); } - Map result = batchRequest.execute(); + + Map result; + try { + result = batchRequest.execute(); + } catch (JsonRpcBatchException e) { + result = (Map)e.getSuccesses(); + } Map blockHeaderMap = new TreeMap<>(); for(Integer height : result.keySet()) { @@ -232,7 +283,7 @@ public class ElectrumServer { } if(!blockHeights.isEmpty()) { - throw new IllegalStateException("Could not retrieve blocks " + blockHeights); + System.out.println("Could not retrieve " + blockHeights.size() + " blocks"); } return blockHeaderMap; @@ -243,6 +294,7 @@ public class ElectrumServer { } } + @SuppressWarnings("unchecked") public Map getTransactions(Set references, Map blockHeaderMap) throws ServerException { try { Set checkReferences = new TreeSet<>(references); @@ -252,11 +304,28 @@ public class ElectrumServer { for(BlockTransactionHash reference : references) { batchRequest.add(reference.getHashAsString(), "blockchain.transaction.get", reference.getHashAsString()); } - Map result = batchRequest.execute(); + + Map result; + try { + result = batchRequest.execute(); + } catch (JsonRpcBatchException e) { + result = (Map)e.getSuccesses(); + for(Object hash : e.getErrors().keySet()) { + String txhash = (String)hash; + result.put(txhash, Sha256Hash.ZERO_HASH.toString()); + } + } Map transactionMap = new HashMap<>(); for(String txid : result.keySet()) { Sha256Hash hash = Sha256Hash.wrap(txid); + + if(hash.equals(Sha256Hash.ZERO_HASH)) { + transactionMap.put(hash, UNFETCHABLE_BLOCK_TRANSACTION); + checkReferences.removeIf(ref -> ref.getHash().equals(hash)); + continue; + } + byte[] rawtx = Utils.hexToBytes(result.get(txid)); Transaction transaction = new Transaction(rawtx); @@ -268,7 +337,9 @@ public class ElectrumServer { BlockHeader blockHeader = blockHeaderMap.get(reference.getHeight()); if(blockHeader == null) { - throw new IllegalStateException("Block header at height " + reference.getHeight() + " not retrieved"); + transactionMap.put(hash, UNFETCHABLE_BLOCK_TRANSACTION); + checkReferences.removeIf(ref -> ref.getHash().equals(hash)); + continue; } BlockTransaction blockchainTransaction = new BlockTransaction(reference.getHash(), reference.getHeight(), blockHeader.getTimeAsDate(), reference.getFee(), transaction); @@ -400,7 +471,20 @@ public class ElectrumServer { return Utils.bytesToHex(reversed); } + private String getScriptHash(TransactionOutput output) { + byte[] hash = Sha256Hash.hash(output.getScript().getProgram()); + byte[] reversed = Utils.reverseBytes(hash); + return Utils.bytesToHex(reversed); + } + private static class ScriptHashTx { + public static final ScriptHashTx ERROR_TX = new ScriptHashTx() { + @Override + public BlockTransactionHash getBlockchainTransactionHash() { + return UNFETCHABLE_BLOCK_TRANSACTION; + } + }; + public int height; public String tx_hash; public long fee; @@ -800,6 +884,62 @@ public class ElectrumServer { } } + public static class TransactionOutputsReferenceService extends Service> { + private final Transaction transaction; + + public TransactionOutputsReferenceService(Transaction transaction) { + this.transaction = transaction; + } + + @Override + protected Task> createTask() { + return new Task<>() { + protected List call() throws ServerException { + ElectrumServer electrumServer = new ElectrumServer(); + List> outputTransactionReferences = electrumServer.getOutputTransactionReferences(transaction); + + Set setReferences = new HashSet<>(); + for(Set outputReferences : outputTransactionReferences) { + setReferences.addAll(outputReferences); + } + setReferences.remove(null); + setReferences.remove(UNFETCHABLE_BLOCK_TRANSACTION); + + List blockTransactions = new ArrayList<>(transaction.getOutputs().size()); + for(int i = 0; i < transaction.getOutputs().size(); i++) { + blockTransactions.add(null); + } + + Map transactionMap = new HashMap<>(); + if(!setReferences.isEmpty()) { + Map blockHeaderMap = electrumServer.getBlockHeaders(setReferences); + transactionMap = electrumServer.getTransactions(setReferences, blockHeaderMap); + } + + for(int i = 0; i < outputTransactionReferences.size(); i++) { + Set outputReferences = outputTransactionReferences.get(i); + for(BlockTransactionHash reference : outputReferences) { + if(reference == UNFETCHABLE_BLOCK_TRANSACTION) { + blockTransactions.set(i, UNFETCHABLE_BLOCK_TRANSACTION); + } else { + BlockTransaction blockTransaction = transactionMap.get(reference.getHash()); + for(TransactionInput input : blockTransaction.getTransaction().getInputs()) { + if(input.getOutpoint().getHash().equals(transaction.getTxId())) { + if(blockTransactions.set(i, blockTransaction) != null) { + throw new IllegalStateException("Double spend detected on hash " + reference.getHash()); + } + } + } + } + } + } + + return blockTransactions; + } + }; + } + } + public enum Protocol { TCP { @Override diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 8b1617b1..ae17a2cb 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -210,6 +210,8 @@ public class HeadersController extends TransactionFormController implements Init Long feeAmt = null; if(headersForm.getPsbt() != null) { feeAmt = headersForm.getPsbt().getFee(); + } else if(headersForm.getTransaction().getInputs().size() == 1 && headersForm.getTransaction().getInputs().get(0).isCoinBase()) { + feeAmt = 0L; } else if(headersForm.getInputTransactions() != null) { feeAmt = calculateFee(headersForm.getInputTransactions()); } @@ -229,6 +231,10 @@ public class HeadersController extends TransactionFormController implements Init private long calculateFee(Map inputTransactions) { long feeAmt = 0L; for(TransactionInput input : headersForm.getTransaction().getInputs()) { + if(input.isCoinBase()) { + return 0L; + } + BlockTransaction inputTx = inputTransactions.get(input.getOutpoint().getHash()); if(inputTx == null) { throw new IllegalStateException("Cannot find transaction for hash " + input.getOutpoint().getHash()); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java index daabb9e0..bf04306e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java @@ -121,7 +121,7 @@ public class InputController extends TransactionFormController implements Initia @Override public void initialize(URL location, ResourceBundle resources) { - + EventManager.get().register(this); } public void initializeView() { @@ -184,13 +184,16 @@ public class InputController extends TransactionFormController implements Initia BlockTransaction linkedTransaction = inputTransactions.get(txInput.getOutpoint().getHash()); EventManager.get().post(new ViewTransactionEvent(linkedTransaction, TransactionView.OUTPUT, (int)txInput.getOutpoint().getIndex())); }); + linkedOutpoint.setContextMenu(new TransactionReferenceContextMenu(linkedOutpoint.getText())); } private void updateSpends(Map inputTransactions) { TransactionInput txInput = inputForm.getTransactionInput(); - BlockTransaction blockTransaction = inputTransactions.get(txInput.getOutpoint().getHash()); - TransactionOutput output = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); - updateSpends(output); + if(!txInput.isCoinBase()) { + BlockTransaction blockTransaction = inputTransactions.get(txInput.getOutpoint().getHash()); + TransactionOutput output = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); + updateSpends(output); + } } private void updateSpends(TransactionOutput output) { diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/InputsController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/InputsController.java index f561171b..7d0fbd6f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/InputsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/InputsController.java @@ -113,13 +113,24 @@ public class InputsController extends TransactionFormController implements Initi foundSigs += input.getScriptSig().getSignatures().size(); } - BlockTransaction inputTx = inputTransactions.get(input.getOutpoint().getHash()); - if(inputTx == null) { - throw new IllegalStateException("Cannot find transaction for hash " + input.getOutpoint().getHash()); - } + if(input.isCoinBase()) { + long totalAmt = 0; + for(TransactionOutput output : inputsForm.getTransaction().getOutputs()) { + totalAmt += output.getValue(); + } + total.setValue(totalAmt); + signatures.setText("N/A"); + addCoinbasePieData(inputsPie, totalAmt); + return; + } else { + BlockTransaction inputTx = inputTransactions.get(input.getOutpoint().getHash()); + if(inputTx == null) { + throw new IllegalStateException("Cannot find transaction for hash " + input.getOutpoint().getHash()); + } - TransactionOutput output = inputTx.getTransaction().getOutputs().get((int)input.getOutpoint().getIndex()); - outputs.add(output); + TransactionOutput output = inputTx.getTransaction().getOutputs().get((int)input.getOutpoint().getIndex()); + outputs.add(output); + } } long totalAmt = 0; diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java index 2b256b1a..61a1b329 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java @@ -1,17 +1,28 @@ package com.sparrowwallet.sparrow.transaction; +import com.google.common.eventbus.Subscribe; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.protocol.NonStandardScriptException; +import com.sparrowwallet.drongo.protocol.TransactionInput; import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.AddressLabel; import com.sparrowwallet.sparrow.control.CoinLabel; import com.sparrowwallet.sparrow.control.CopyableLabel; +import com.sparrowwallet.sparrow.event.BlockTransactionOutputsFetchedEvent; +import com.sparrowwallet.sparrow.event.ViewTransactionEvent; +import com.sparrowwallet.sparrow.io.ElectrumServer; import javafx.fxml.FXML; import javafx.fxml.Initializable; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; import org.fxmisc.richtext.CodeArea; +import tornadofx.control.Field; import tornadofx.control.Fieldset; import java.net.URL; +import java.util.List; import java.util.ResourceBundle; public class OutputController extends TransactionFormController implements Initializable { @@ -29,12 +40,24 @@ public class OutputController extends TransactionFormController implements Initi @FXML private AddressLabel address; + @FXML + private Field spentField; + + @FXML + private Label spent; + + @FXML + private Field spentByField; + + @FXML + private Hyperlink spentBy; + @FXML private CodeArea scriptPubKeyArea; @Override public void initialize(URL location, ResourceBundle resources) { - + EventManager.get().register(this); } public void initializeView() { @@ -56,12 +79,63 @@ public class OutputController extends TransactionFormController implements Initi //ignore } + spentField.managedProperty().bind(spentField.visibleProperty()); + spentByField.managedProperty().bind(spentByField.visibleProperty()); + spentByField.setVisible(false); + + if(outputForm.getPsbt() != null) { + spent.setText("Unspent"); + } else if(outputForm.getOutputTransactions() != null) { + updateSpent(outputForm.getOutputTransactions()); + } else { + spent.setText("Unknown"); + } + scriptPubKeyArea.clear(); appendScript(scriptPubKeyArea, txOutput.getScript(), null, null); } + private void updateSpent(List outputTransactions) { + int outputIndex = outputForm.getTransactionOutputIndex(); + spent.setText("Unspent"); + + if(outputIndex >= 0 && outputIndex < outputTransactions.size()) { + BlockTransaction outputBlockTransaction = outputTransactions.get(outputIndex); + if(outputBlockTransaction != null) { + spent.setText("Spent"); + + if(outputBlockTransaction == ElectrumServer.UNFETCHABLE_BLOCK_TRANSACTION) { + spent.setText("Spent (Spending transaction history too large to fetch)"); + return; + } + + for(int i = 0; i < outputBlockTransaction.getTransaction().getInputs().size(); i++) { + TransactionInput input = outputBlockTransaction.getTransaction().getInputs().get(i); + if(input.getOutpoint().getHash().equals(outputForm.getTransaction().getTxId()) && input.getOutpoint().getIndex() == outputIndex) { + spentField.setVisible(false); + spentByField.setVisible(true); + + final Integer inputIndex = i; + spentBy.setText(outputBlockTransaction.getHash().toString() + ":" + inputIndex); + spentBy.setOnAction(event -> { + EventManager.get().post(new ViewTransactionEvent(outputBlockTransaction, TransactionView.INPUT, inputIndex)); + }); + spentBy.setContextMenu(new TransactionReferenceContextMenu(spentBy.getText())); + } + } + } + } + } + public void setModel(OutputForm form) { this.outputForm = form; initializeView(); } + + @Subscribe + public void blockTransactionOutputsFetched(BlockTransactionOutputsFetchedEvent event) { + if(event.getTxId().equals(outputForm.getTransaction().getTxId()) && outputForm.getPsbt() == null) { + updateSpent(event.getOutputTransactions()); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java index 50bbf49d..2a037e02 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java @@ -11,7 +11,7 @@ import javafx.scene.Node; import java.io.IOException; public class OutputForm extends TransactionForm { - private TransactionOutput transactionOutput; + private final TransactionOutput transactionOutput; private PSBTOutput psbtOutput; public OutputForm(PSBT psbt, PSBTOutput psbtOutput) { @@ -38,6 +38,10 @@ public class OutputForm extends TransactionForm { return psbtOutput; } + public int getTransactionOutputIndex() { + return getTransaction().getOutputs().indexOf(transactionOutput); + } + @Override public Node getContents() throws IOException { FXMLLoader loader = new FXMLLoader(getClass().getResource("output.fxml")); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java index c0c50be0..9a427422 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java @@ -62,7 +62,8 @@ public class TransactionController implements Initializable { initializeTxTree(); transactionMasterDetail.setShowDetailNode(AppController.showTxHexProperty); refreshTxHex(); - fetchBlockTransactions(); + fetchThisAndInputBlockTransactions(); + fetchOutputBlockTransactions(); } private void initializeTxTree() { @@ -134,7 +135,7 @@ public class TransactionController implements Initializable { selectedOutputIndex = outputForm.getTransactionOutput().getIndex(); } - refreshTxHex(); + Platform.runLater(this::refreshTxHex); } } catch (IOException e) { throw new IllegalStateException("Can't find pane", e); @@ -149,9 +150,9 @@ public class TransactionController implements Initializable { } private void select(TreeItem treeItem, TransactionView view, Integer index) { - if(treeItem.getValue().getView().equals(view)) { - if(view.equals(TransactionView.INPUT) || view.equals(TransactionView.OUTPUT)) { - if(treeItem.getParent().getChildren().indexOf(treeItem) == index) { + if (treeItem.getValue().getView().equals(view)) { + if (view.equals(TransactionView.INPUT) || view.equals(TransactionView.OUTPUT)) { + if (treeItem.getParent().getChildren().indexOf(treeItem) == index) { txtree.getSelectionModel().select(treeItem); return; } @@ -161,12 +162,13 @@ public class TransactionController implements Initializable { } } - for(TreeItem childItem : treeItem.getChildren()) { + for (TreeItem childItem : treeItem.getChildren()) { select(childItem, view, index); } } void refreshTxHex() { + //TODO: Handle large transactions like efd513fffbbc2977c2d3933dfaab590b5cab5841ee791b3116e531ac9f8034ed better by not replacing text txhex.clear(); String hex = ""; @@ -242,14 +244,16 @@ public class TransactionController implements Initializable { } } - private void fetchBlockTransactions() { - if(AppController.isOnline()) { + private void fetchThisAndInputBlockTransactions() { + if (AppController.isOnline()) { Set references = new HashSet<>(); - if(psbt == null) { + if (psbt == null) { references.add(transaction.getTxId()); } - for(TransactionInput input : transaction.getInputs()) { - references.add(input.getOutpoint().getHash()); + for (TransactionInput input : transaction.getInputs()) { + if(!input.isCoinBase()) { + references.add(input.getOutpoint().getHash()); + } } ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(references); @@ -257,9 +261,9 @@ public class TransactionController implements Initializable { Map transactionMap = transactionReferenceService.getValue(); BlockTransaction thisBlockTx = null; Map inputTransactions = new HashMap<>(); - for(Sha256Hash txid : transactionMap.keySet()) { + for (Sha256Hash txid : transactionMap.keySet()) { BlockTransaction blockTx = transactionMap.get(txid); - if(txid.equals(transaction.getTxId())) { + if (txid.equals(transaction.getTxId())) { thisBlockTx = blockTx; } else { inputTransactions.put(txid, blockTx); @@ -268,7 +272,7 @@ public class TransactionController implements Initializable { } references.remove(transaction.getTxId()); - if(!references.isEmpty()) { + if (!references.isEmpty()) { System.out.println("Failed to retrieve all referenced input transactions, aborting transaction fetch"); return; } @@ -285,6 +289,22 @@ public class TransactionController implements Initializable { } } + private void fetchOutputBlockTransactions() { + if (AppController.isOnline() && psbt == null) { + ElectrumServer.TransactionOutputsReferenceService transactionOutputsReferenceService = new ElectrumServer.TransactionOutputsReferenceService(transaction); + transactionOutputsReferenceService.setOnSucceeded(successEvent -> { + List outputTransactions = transactionOutputsReferenceService.getValue(); + Platform.runLater(() -> { + EventManager.get().post(new BlockTransactionOutputsFetchedEvent(transaction.getTxId(), outputTransactions)); + }); + }); + transactionOutputsReferenceService.setOnFailed(failedEvent -> { + failedEvent.getSource().getException().printStackTrace(); + }); + transactionOutputsReferenceService.start(); + } + } + private String getIndexedStyleClass(int iterableIndex, int selectedIndex, String styleClass) { if (selectedIndex == -1 || selectedIndex == iterableIndex) { return styleClass; @@ -314,7 +334,7 @@ public class TransactionController implements Initializable { @Subscribe public void transactionChanged(TransactionChangedEvent event) { - if(event.getTransaction().equals(transaction)) { + if (event.getTransaction().equals(transaction)) { refreshTxHex(); txtree.refresh(); } @@ -332,7 +352,7 @@ public class TransactionController implements Initializable { @Subscribe public void blockTransactionFetched(BlockTransactionFetchedEvent event) { - if(event.getTxId().equals(transaction.getTxId())) { + if (event.getTxId().equals(transaction.getTxId())) { setBlockTransaction(txtree.getRoot(), event); } } @@ -342,8 +362,24 @@ public class TransactionController implements Initializable { form.setBlockTransaction(event.getBlockTransaction()); form.setInputTransactions(event.getInputTransactions()); - for(TreeItem childItem : treeItem.getChildren()) { + for (TreeItem childItem : treeItem.getChildren()) { setBlockTransaction(childItem, event); } } -} + + @Subscribe + public void blockTransactionOutputsFetched(BlockTransactionOutputsFetchedEvent event) { + if (event.getTxId().equals(transaction.getTxId())) { + setBlockTransactionOutputs(txtree.getRoot(), event); + } + } + + private void setBlockTransactionOutputs(TreeItem treeItem, BlockTransactionOutputsFetchedEvent event) { + TransactionForm form = treeItem.getValue(); + form.setOutputTransactions(event.getOutputTransactions()); + + for (TreeItem childItem : treeItem.getChildren()) { + setBlockTransactionOutputs(childItem, event); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java index b8c88c00..4b714b51 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java @@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.wallet.BlockTransaction; import javafx.scene.Node; import java.io.IOException; +import java.util.List; import java.util.Map; public abstract class TransactionForm { @@ -14,6 +15,7 @@ public abstract class TransactionForm { private PSBT psbt; private BlockTransaction blockTransaction; private Map inputTransactions; + private List outputTransactions; public TransactionForm(PSBT psbt) { this.transaction = psbt.getTransaction(); @@ -53,6 +55,14 @@ public abstract class TransactionForm { this.inputTransactions = inputTransactions; } + public List getOutputTransactions() { + return outputTransactions; + } + + public void setOutputTransactions(List outputTransactions) { + this.outputTransactions = outputTransactions; + } + public boolean isEditable() { return blockTransaction == null; } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java index 4621a19b..932fd86d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java @@ -7,7 +7,11 @@ import com.sparrowwallet.sparrow.BaseController; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.chart.PieChart; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; import javafx.scene.control.Tooltip; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; import java.util.List; @@ -33,9 +37,17 @@ public abstract class TransactionFormController extends BaseController { outputsPieData.add(new PieChart.Data(name, output.getValue())); } - pie.setData(outputsPieData); + addPieData(pie, outputsPieData); + } - final double totalSum = totalAmt; + protected void addCoinbasePieData(PieChart pie, long value) { + ObservableList outputsPieData = FXCollections.observableList(List.of(new PieChart.Data("Coinbase", value))); + addPieData(pie, outputsPieData); + } + + private void addPieData(PieChart pie, ObservableList outputsPieData) { + pie.setData(outputsPieData); + final double totalSum = outputsPieData.stream().map(PieChart.Data::getPieValue).mapToDouble(Double::doubleValue).sum(); pie.getData().forEach(data -> { Tooltip tooltip = new Tooltip(); double percent = 100.0 * (data.getPieValue() / totalSum); @@ -44,4 +56,17 @@ public abstract class TransactionFormController extends BaseController { data.pieValueProperty().addListener((observable, oldValue, newValue) -> tooltip.setText(newValue + "%")); }); } + + public static class TransactionReferenceContextMenu extends ContextMenu { + public TransactionReferenceContextMenu(String reference) { + MenuItem referenceItem = new MenuItem("Copy Reference"); + referenceItem.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(reference); + Clipboard.getSystemClipboard().setContent(content); + }); + getItems().add(referenceItem); + } + } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index 24db6b6c..08683698 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -18,6 +18,7 @@ + diff --git a/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml b/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml index 5afa796d..eae921c9 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml @@ -33,7 +33,7 @@
- +