From e83c02653c0b81c649b56203ea7a0de202249769 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 22 Feb 2022 12:04:39 +0200 Subject: [PATCH] implement bip47 (linking, sending to and receiving from paynyms) --- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 30 +- .../sparrowwallet/sparrow/AppServices.java | 6 + .../sparrow/control/MessageSignDialog.java | 6 +- .../sparrow/control/PayNymCell.java | 32 ++ .../control/PrivateKeySweepDialog.java | 3 +- .../sparrow/control/TransactionDiagram.java | 4 +- .../event/WalletNodeHistoryChangedEvent.java | 13 +- .../sparrow/glyphfont/FontAwesome5.java | 1 + .../sparrow/io/JsonPersistence.java | 2 +- .../sparrow/io/db/KeystoreDao.java | 11 +- .../sparrow/io/db/KeystoreMapper.java | 2 + .../sparrow/io/db/WalletNodeDao.java | 1 + .../sparrow/net/ElectrumServer.java | 174 ++++++++-- .../sparrow/payjoin/Payjoin.java | 2 +- .../soroban/CounterpartyController.java | 4 +- .../sparrow/soroban/InitiatorController.java | 2 +- .../sparrowwallet/sparrow/soroban/PayNym.java | 55 ++- .../sparrow/soroban/PayNymController.java | 315 ++++++++++++++++-- .../sparrow/soroban/PayNymDialog.java | 17 +- .../sparrow/soroban/PayNymService.java | 4 +- .../sparrow/soroban/Soroban.java | 8 +- .../sparrow/soroban/SorobanController.java | 6 +- .../transaction/HeadersController.java | 7 +- .../sparrow/transaction/OutputController.java | 8 +- .../sparrow/transaction/OutputForm.java | 2 +- .../transaction/TransactionController.java | 6 +- .../sparrow/wallet/KeystoreController.java | 1 + .../sparrow/wallet/PaymentController.java | 80 ++++- .../sparrow/wallet/SendController.java | 17 +- .../sparrow/wallet/WalletForm.java | 91 +++-- .../wallet/WalletTransactionsEntry.java | 5 +- .../dataSource/SparrowPostmixHandler.java | 3 +- .../sparrowwallet/sparrow/soroban/paynym.css | 8 + .../sparrow/sql/V6__PaymentCode.sql | 1 + 35 files changed, 771 insertions(+), 158 deletions(-) create mode 100644 src/main/resources/com/sparrowwallet/sparrow/sql/V6__PaymentCode.sql diff --git a/drongo b/drongo index f73cabad..7bb07ab3 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit f73cabad3c76c1eb28b4f02b17c9beb608ba2aa4 +Subproject commit 7bb07ab39eafc0de54d3dc2e19a444d39f9a1fc3 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 8551272a..3c2c4ee2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -343,9 +343,10 @@ public class AppController implements Initializable { 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()); + showPayNym.setDisable(true); findMixingPartner.setDisable(true); AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { + showPayNym.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !getSelectedWalletForm().getWallet().hasPaymentCode() || !newValue); findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue); }); @@ -979,7 +980,7 @@ public class AppController implements Initializable { } private void restorePublicKeysFromSeed(Storage storage, Wallet wallet, Key key) throws MnemonicException { - if(wallet.containsPrivateKeys()) { + if(wallet.containsMasterPrivateKeys()) { //Derive xpub and master fingerprint from seed, potentially with passphrase Wallet copy = wallet.copy(); for(int i = 0; i < copy.getKeystores().size(); i++) { @@ -1037,16 +1038,27 @@ public class AppController implements Initializable { keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase()); + keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); copyKeystore.getSeed().clear(); } else if(keystore.hasMasterPrivateExtendedKey()) { Keystore copyKeystore = copy.getKeystores().get(i); Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation()); keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); + keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); copyKeystore.getMasterPrivateKey().clear(); } } } + + if(wallet.isBip47()) { + try { + Keystore keystore = wallet.getKeystores().get(0); + keystore.setBip47ExtendedPrivateKey(wallet.getMasterWallet().getKeystores().get(0).getBip47ExtendedPrivateKey()); + } catch(Exception e) { + log.error("Cannot prepare BIP47 keystore", e); + } + } } public void importWallet(ActionEvent event) { @@ -1342,7 +1354,7 @@ public class AppController implements Initializable { public void showPayNym(ActionEvent event) { WalletForm selectedWalletForm = getSelectedWalletForm(); if(selectedWalletForm != null) { - PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId(), false); + PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId()); payNymDialog.showAndWait(); } } @@ -1961,6 +1973,7 @@ public class AppController implements Initializable { exportWallet.setDisable(true); showLoadingLog.setDisable(true); showTxHex.setDisable(false); + showPayNym.setDisable(true); findMixingPartner.setDisable(true); } else if(event instanceof WalletTabSelectedEvent) { WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event; @@ -1971,6 +1984,7 @@ public class AppController implements Initializable { exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid() || walletTabData.getWalletForm().isLocked()); showLoadingLog.setDisable(false); showTxHex.setDisable(true); + showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get()); findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get()); } } @@ -1996,6 +2010,7 @@ public class AppController implements Initializable { if(selectedWalletForm != null) { if(selectedWalletForm.getWalletId().equals(event.getWalletId())) { exportWallet.setDisable(!event.getWallet().isValid() || selectedWalletForm.isLocked()); + showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get()); findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get()); } } @@ -2075,12 +2090,9 @@ public class AppController implements Initializable { }); Image image = new Image("image/sparrow-small.png", 50, 50, false, false); - String walletName = event.getWallet().getMasterName(); - if(walletName.length() > 25) { - walletName = walletName.substring(0, 25) + "..."; - } - if(!event.getWallet().isMasterWallet()) { - walletName += " " + event.getWallet().getName(); + String walletName = event.getWallet().getFullDisplayName(); + if(walletName.length() > 40) { + walletName = walletName.substring(0, 40) + "..."; } Notifications notificationBuilder = Notifications.create() diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 930136fb..4b4e58e6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -610,6 +610,12 @@ public class AppServices { return getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getTargetBlockFeeRates().get(defaultTarget); } + public static Double getMinimumFeeRate() { + Optional optMinFeeRate = getTargetBlockFeeRates().values().stream().min(Double::compareTo); + Double minRate = optMinFeeRate.orElse(FALLBACK_FEE_RATE); + return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE); + } + public static Map getTargetBlockFeeRates() { return targetBlockFeeRates; } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java index 63a6a12f..1478185a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java @@ -94,7 +94,7 @@ public class MessageSignDialog extends Dialog { throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required"); } if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) { - throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed or USB keystore"); + throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a USB keystore"); } } @@ -301,7 +301,8 @@ public class MessageSignDialog extends Dialog { return; } - if(wallet.containsPrivateKeys()) { + //Note we can expect a single keystore due to the check in the constructor + if(wallet.getKeystores().get(0).hasPrivateKey()) { if(wallet.isEncrypted()) { EventManager.get().post(new RequestOpenWalletsEvent()); } else { @@ -314,7 +315,6 @@ public class MessageSignDialog extends Dialog { private void signUnencryptedKeystore(Wallet decryptedWallet) { try { - //Note we can expect a single keystore due to the check above Keystore keystore = decryptedWallet.getKeystores().get(0); ECKey privKey = keystore.getKey(walletNode); ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java b/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java index 40052de9..68147ea2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java @@ -1,5 +1,6 @@ package com.sparrowwallet.sparrow.control; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.soroban.PayNym; import com.sparrowwallet.sparrow.soroban.PayNymController; import javafx.geometry.Insets; @@ -7,6 +8,7 @@ import javafx.geometry.Pos; import javafx.scene.control.*; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import org.controlsfx.glyphfont.Glyph; public class PayNymCell extends ListCell { private final PayNymController payNymController; @@ -24,6 +26,8 @@ public class PayNymCell extends ListCell { protected void updateItem(PayNym payNym, boolean empty) { super.updateItem(payNym, empty); + getStyleClass().remove("unlinked"); + if(empty || payNym == null) { setText(null); setGraphic(null); @@ -53,10 +57,38 @@ public class PayNymCell extends ListCell { button.setDisable(true); payNymController.followPayNym(payNym.paymentCode()); }); + } else if(payNymController != null) { + HBox hBox = new HBox(); + hBox.setAlignment(Pos.CENTER); + pane.setRight(hBox); + + if(payNymController.isLinked(payNym)) { + Label linkedLabel = new Label("Linked", getLinkGlyph()); + linkedLabel.setTooltip(new Tooltip("You can send non-collaboratively to this contact.")); + hBox.getChildren().add(linkedLabel); + } else { + Button linkButton = new Button("Link Contact", getLinkGlyph()); + linkButton.setTooltip(new Tooltip("Create a transaction that will enable you to send non-collaboratively to this contact.")); + hBox.getChildren().add(linkButton); + linkButton.setOnAction(event -> { + linkButton.setDisable(true); + payNymController.linkPayNym(payNym); + }); + + if(payNymController.isSelectLinkedOnly()) { + getStyleClass().add("unlinked"); + } + } } setText(null); setGraphic(pane); } } + + public static Glyph getLinkGlyph() { + Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LINK); + failGlyph.setFontSize(12); + return failGlyph; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java index 13c8f1f3..07d78274 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java @@ -123,7 +123,8 @@ public class PrivateKeySweepDialog extends Dialog { toAddress = new ComboBoxTextField(); toAddress.getStyleClass().add("fixed-width"); toWallet = new ComboBox<>(); - toWallet.setItems(FXCollections.observableList(AppServices.get().getOpenWallets().keySet().stream().filter(w -> !w.isWhirlpoolChildWallet()).collect(Collectors.toList()))); + toWallet.setItems(FXCollections.observableList(AppServices.get().getOpenWallets().keySet().stream() + .filter(w -> !w.isWhirlpoolChildWallet() && !w.isBip47()).collect(Collectors.toList()))); toAddress.setComboProperty(toWallet); toWallet.prefWidthProperty().bind(toAddress.widthProperty()); StackPane stackPane = new StackPane(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index c58ca611..cf927d3d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -647,7 +647,7 @@ public class TransactionDiagram extends GridPane { recipientLabel.getStyleClass().add("output-label"); recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label"); Wallet toWallet = getToWallet(payment); - WalletNode toNode = walletTx.getWallet() != null ? walletTx.getWallet().getWalletAddresses().get(payment.getAddress()) : null; + WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getWallet().getWalletAddresses().get(payment.getAddress()) : null; Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : "external address") : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())); @@ -849,7 +849,7 @@ public class TransactionDiagram extends GridPane { private Wallet getToWallet(Payment payment) { for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) { - if(openWallet != walletTx.getWallet() && openWallet.isValid() && openWallet.isWalletAddress(payment.getAddress())) { + if(openWallet != walletTx.getWallet() && openWallet.isValid() && !openWallet.isBip47() && openWallet.isWalletAddress(payment.getAddress())) { return openWallet; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java index 40badf36..63c192c2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java @@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.WalletNode; import com.sparrowwallet.sparrow.net.ElectrumServer; +import java.util.ArrayList; import java.util.List; /** @@ -26,12 +27,20 @@ public class WalletNodeHistoryChangedEvent { } } + Wallet notificationWallet = wallet.getNotificationWallet(); + if(notificationWallet != null) { + WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION); + if(ElectrumServer.getScriptHash(notificationWallet, notificationNode).equals(scriptHash)) { + return notificationNode; + } + } + return null; } private WalletNode getWalletNode(Wallet wallet, KeyPurpose keyPurpose) { - WalletNode purposeNode = wallet.getNode(keyPurpose); - for(WalletNode addressNode : purposeNode.getChildren()) { + WalletNode purposeNode = wallet.getNode(keyPurpose); + for(WalletNode addressNode : new ArrayList<>(purposeNode.getChildren())) { if(ElectrumServer.getScriptHash(wallet, addressNode).equals(scriptHash)) { return addressNode; } diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index 45311083..c816db53 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -45,6 +45,7 @@ public class FontAwesome5 extends GlyphFont { INFO_CIRCLE('\uf05a'), KEY('\uf084'), LAPTOP('\uf109'), + LINK('\uf0c1'), LOCK('\uf023'), LOCK_OPEN('\uf3c1'), MINUS_CIRCLE('\uf056'), diff --git a/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java index 9ac0aa73..c253ae41 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java @@ -425,7 +425,7 @@ public class JsonPersistence implements Persistence { @Override public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) { JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(keystore); - if(keystore.hasPrivateKey()) { + if(keystore.hasMasterPrivateKey()) { jsonObject.remove("extendedPublicKey"); jsonObject.getAsJsonObject("keyDerivation").remove("masterFingerprint"); } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java b/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java index b6e4929b..68c66c85 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java @@ -13,16 +13,16 @@ import org.jdbi.v3.sqlobject.statement.SqlUpdate; import java.util.List; public interface KeystoreDao { - @SqlQuery("select keystore.id, keystore.label, keystore.source, keystore.walletModel, keystore.masterFingerprint, keystore.derivationPath, keystore.extendedPublicKey, " + + @SqlQuery("select keystore.id, keystore.label, keystore.source, keystore.walletModel, keystore.masterFingerprint, keystore.derivationPath, keystore.extendedPublicKey, keystore.externalPaymentCode, " + "masterPrivateExtendedKey.id, masterPrivateExtendedKey.privateKey, masterPrivateExtendedKey.chainCode, masterPrivateExtendedKey.initialisationVector, masterPrivateExtendedKey.encryptedBytes, masterPrivateExtendedKey.keySalt, masterPrivateExtendedKey.deriver, masterPrivateExtendedKey.crypter, " + "seed.id, seed.type, seed.mnemonicString, seed.initialisationVector, seed.encryptedBytes, seed.keySalt, seed.deriver, seed.crypter, seed.needsPassphrase, seed.creationTimeSeconds " + "from keystore left join masterPrivateExtendedKey on keystore.masterPrivateExtendedKey = masterPrivateExtendedKey.id left join seed on keystore.seed = seed.id where keystore.wallet = ? order by keystore.index asc") @RegisterRowMapper(KeystoreMapper.class) List getForWalletId(Long id); - @SqlUpdate("insert into keystore (label, source, walletModel, masterFingerprint, derivationPath, extendedPublicKey, masterPrivateExtendedKey, seed, wallet, index) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + @SqlUpdate("insert into keystore (label, source, walletModel, masterFingerprint, derivationPath, extendedPublicKey, externalPaymentCode, masterPrivateExtendedKey, seed, wallet, index) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") @GetGeneratedKeys("id") - long insert(String label, int source, int walletModel, String masterFingerprint, String derivationPath, String extendedPublicKey, Long masterPrivateExtendedKey, Long seed, long wallet, int index); + long insert(String label, int source, int walletModel, String masterFingerprint, String derivationPath, String extendedPublicKey, String externalPaymentCode, Long masterPrivateExtendedKey, Long seed, long wallet, int index); @SqlUpdate("insert into masterPrivateExtendedKey (privateKey, chainCode, initialisationVector, encryptedBytes, keySalt, deriver, crypter, creationTimeSeconds) values (?, ?, ?, ?, ?, ?, ?, ?)") @GetGeneratedKeys("id") @@ -69,9 +69,10 @@ public interface KeystoreDao { } long id = insert(truncate(keystore.getLabel()), keystore.getSource().ordinal(), keystore.getWalletModel().ordinal(), - keystore.hasPrivateKey() ? null : keystore.getKeyDerivation().getMasterFingerprint(), + keystore.hasMasterPrivateKey() ? null : keystore.getKeyDerivation().getMasterFingerprint(), keystore.getKeyDerivation().getDerivationPath(), - keystore.hasPrivateKey() ? null : keystore.getExtendedPublicKey().toString(), + keystore.hasMasterPrivateKey() ? null : keystore.getExtendedPublicKey().toString(), + keystore.getExternalPaymentCode() == null ? null : keystore.getExternalPaymentCode().toString(), keystore.getMasterPrivateExtendedKey() == null ? null : keystore.getMasterPrivateExtendedKey().getId(), keystore.getSeed() == null ? null : keystore.getSeed().getId(), wallet.getId(), i); keystore.setId(id); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreMapper.java b/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreMapper.java index 222c2c3a..977c6f61 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreMapper.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreMapper.java @@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io.db; import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.crypto.EncryptedData; import com.sparrowwallet.drongo.crypto.EncryptionType; import com.sparrowwallet.drongo.wallet.*; @@ -23,6 +24,7 @@ public class KeystoreMapper implements RowMapper { keystore.setWalletModel(WalletModel.values()[rs.getInt("keystore.walletModel")]); keystore.setKeyDerivation(new KeyDerivation(rs.getString("keystore.masterFingerprint"), rs.getString("keystore.derivationPath"))); keystore.setExtendedPublicKey(rs.getString("keystore.extendedPublicKey") == null ? null : ExtendedKey.fromDescriptor(rs.getString("keystore.extendedPublicKey"))); + keystore.setExternalPaymentCode(rs.getString("keystore.externalPaymentCode") == null ? null : PaymentCode.fromString(rs.getString("keystore.externalPaymentCode"))); if(rs.getBytes("masterPrivateExtendedKey.privateKey") != null) { MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(rs.getBytes("masterPrivateExtendedKey.privateKey"), rs.getBytes("masterPrivateExtendedKey.chainCode")); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeDao.java b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeDao.java index 6d8a1c43..39cc2c4e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeDao.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeDao.java @@ -61,6 +61,7 @@ public interface WalletNodeDao { for(WalletNode purposeNode : wallet.getPurposeNodes()) { long purposeNodeId = insertWalletNode(purposeNode.getDerivationPath(), truncate(purposeNode.getLabel()), wallet.getId(), null); purposeNode.setId(purposeNodeId); + addTransactionOutputs(purposeNode); List childNodes = new ArrayList<>(purposeNode.getChildren()); for(WalletNode addressNode : childNodes) { long addressNodeId = insertWalletNode(addressNode.getDerivationPath(), truncate(addressNode.getLabel()), wallet.getId(), purposeNodeId); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 3645c2ee..56a6936e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -7,12 +7,16 @@ 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.bip47.InvalidPaymentCodeException; +import com.sparrowwallet.drongo.bip47.PaymentCode; 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.soroban.PayNym; +import com.sparrowwallet.sparrow.soroban.Soroban; import javafx.application.Platform; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; @@ -172,6 +176,12 @@ public class ElectrumServer { getCalculatedScriptHashes(wallet).forEach(retrievedScriptHashes::putIfAbsent); } + private static void addCalculatedScriptHashes(Wallet wallet, WalletNode walletNode) { + Map calculatedScriptHashStatuses = new HashMap<>(); + addScriptHashStatus(calculatedScriptHashStatuses, wallet, walletNode); + calculatedScriptHashStatuses.forEach(retrievedScriptHashes::putIfAbsent); + } + private static Map getCalculatedScriptHashes(Wallet wallet) { Map storedScriptHashStatuses = new HashMap<>(); storedScriptHashStatuses.putAll(calculateScriptHashes(wallet, KeyPurpose.RECEIVE)); @@ -182,43 +192,51 @@ public class ElectrumServer { private static Map calculateScriptHashes(Wallet wallet, KeyPurpose keyPurpose) { Map calculatedScriptHashes = new LinkedHashMap<>(); for(WalletNode walletNode : wallet.getNode(keyPurpose).getChildren()) { - String scriptHash = getScriptHash(wallet, walletNode); - - List txos = new ArrayList<>(walletNode.getTransactionOutputs()); - txos.addAll(walletNode.getTransactionOutputs().stream().filter(BlockTransactionHashIndex::isSpent).map(BlockTransactionHashIndex::getSpentBy).collect(Collectors.toList())); - Set unique = new HashSet<>(txos.size()); - txos.removeIf(ref -> !unique.add(ref.getHash())); - txos.sort((txo1, txo2) -> { - if(txo1.getHeight() != txo2.getHeight()) { - return txo1.getComparisonHeight() - txo2.getComparisonHeight(); - } - - if(txo1.isSpent() && txo1.getSpentBy().equals(txo2)) { - return -1; - } - - if(txo2.isSpent() && txo2.getSpentBy().equals(txo1)) { - return 1; - } - - //We cannot further sort by order within a block, so sometimes multiple txos to an address will mean an incorrect status - return 0; - }); - if(!txos.isEmpty()) { - StringBuilder scriptHashStatus = new StringBuilder(); - for(BlockTransactionHashIndex txo : txos) { - scriptHashStatus.append(txo.getHash().toString()).append(":").append(txo.getHeight()).append(":"); - } - - calculatedScriptHashes.put(scriptHash, Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8)))); - } else { - calculatedScriptHashes.put(scriptHash, null); - } + addScriptHashStatus(calculatedScriptHashes, wallet, walletNode); } return calculatedScriptHashes; } + private static void addScriptHashStatus(Map calculatedScriptHashes, Wallet wallet, WalletNode walletNode) { + String scriptHash = getScriptHash(wallet, walletNode); + String scriptHashStatus = getScriptHashStatus(walletNode); + calculatedScriptHashes.put(scriptHash, scriptHashStatus); + } + + private static String getScriptHashStatus(WalletNode walletNode) { + List txos = new ArrayList<>(walletNode.getTransactionOutputs()); + txos.addAll(walletNode.getTransactionOutputs().stream().filter(BlockTransactionHashIndex::isSpent).map(BlockTransactionHashIndex::getSpentBy).collect(Collectors.toList())); + Set unique = new HashSet<>(txos.size()); + txos.removeIf(ref -> !unique.add(ref.getHash())); + txos.sort((txo1, txo2) -> { + if(txo1.getHeight() != txo2.getHeight()) { + return txo1.getComparisonHeight() - txo2.getComparisonHeight(); + } + + if(txo1.isSpent() && txo1.getSpentBy().equals(txo2)) { + return -1; + } + + if(txo2.isSpent() && txo2.getSpentBy().equals(txo1)) { + return 1; + } + + //We cannot further sort by order within a block, so sometimes multiple txos to an address will mean an incorrect status + return 0; + }); + if(!txos.isEmpty()) { + StringBuilder scriptHashStatus = new StringBuilder(); + for(BlockTransactionHashIndex txo : txos) { + scriptHashStatus.append(txo.getHash().toString()).append(":").append(txo.getHeight()).append(":"); + } + + return Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8))); + } else { + return null; + } + } + public static void clearRetrievedScriptHashes(Wallet wallet) { wallet.getNode(KeyPurpose.RECEIVE).getChildren().stream().map(node -> getScriptHash(wallet, node)).forEach(scriptHash -> retrievedScriptHashes.remove(scriptHash)); wallet.getNode(KeyPurpose.CHANGE).getChildren().stream().map(node -> getScriptHash(wallet, node)).forEach(scriptHash -> retrievedScriptHashes.remove(scriptHash)); @@ -421,8 +439,8 @@ public class ElectrumServer { String scriptHash = getScriptHash(wallet, node); String subscribedStatus = getSubscribedScriptHashStatus(scriptHash); if(subscribedStatus != null) { - //Already subscribed, but still need to fetch history from a used node if not previously fetched - if(!subscribedStatus.equals(retrievedScriptHashes.get(scriptHash))) { + //Already subscribed, but still need to fetch history from a used node if not previously fetched or present + if(!subscribedStatus.equals(retrievedScriptHashes.get(scriptHash)) || !subscribedStatus.equals(getScriptHashStatus(node))) { nodeTransactionMap.put(node, new TreeSet<>()); } } else if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) { @@ -1632,4 +1650,92 @@ public class ElectrumServer { }; } } + + public static class PaymentCodesService extends Service> { + private final String walletId; + private final Wallet wallet; + + public PaymentCodesService(String walletId, Wallet wallet) { + this.walletId = walletId; + this.wallet = wallet; + } + + @Override + protected Task> createTask() { + return new Task<>() { + protected List call() throws ServerException { + Wallet notificationWallet = wallet.getNotificationWallet(); + WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION); + + for(Wallet childWallet : wallet.getChildWallets()) { + if(childWallet.isBip47()) { + WalletNode savedNotificationNode = childWallet.getNode(KeyPurpose.NOTIFICATION); + notificationNode.getTransactionOutputs().addAll(savedNotificationNode.getTransactionOutputs()); + notificationWallet.updateTransactions(childWallet.getTransactions()); + } + } + + addCalculatedScriptHashes(notificationWallet, notificationNode); + + ElectrumServer electrumServer = new ElectrumServer(); + Map> nodeTransactionMap = electrumServer.getHistory(notificationWallet, List.of(notificationNode)); + electrumServer.getReferencedTransactions(notificationWallet, nodeTransactionMap); + electrumServer.calculateNodeHistory(notificationWallet, nodeTransactionMap); + + List addedWallets = new ArrayList<>(); + if(!nodeTransactionMap.isEmpty()) { + Set paymentCodes = new LinkedHashSet<>(); + for(BlockTransactionHashIndex output : notificationNode.getTransactionOutputs()) { + BlockTransaction blkTx = notificationWallet.getTransactions().get(output.getHash()); + try { + PaymentCode paymentCode = PaymentCode.getPaymentCode(blkTx.getTransaction(), notificationWallet.getKeystores().get(0)); + if(paymentCodes.add(paymentCode)) { + if(getExistingChildWallet(paymentCode) == null) { + PayNym payNym = Config.get().isUsePayNym() ? getPayNym(paymentCode) : null; + List scriptTypes = payNym == null || wallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes(); + for(ScriptType childScriptType : scriptTypes) { + Wallet addedWallet = wallet.addChildWallet(paymentCode, childScriptType, output, blkTx); + if(payNym != null) { + addedWallet.setLabel(payNym.nymName() + " " + childScriptType.getName()); + } + //Check this is a valid payment code, will throw IllegalArgumentException if not + addedWallet.getPubKey(new WalletNode(KeyPurpose.RECEIVE, 0)); + addedWallets.add(addedWallet); + } + } + } + } catch(InvalidPaymentCodeException e) { + log.info("Could not determine payment code for notification transaction", e); + } catch(IllegalArgumentException e) { + log.info("Invalid notification transaction creates illegal payment code", e); + } + } + } + + return addedWallets; + } + }; + } + + private PayNym getPayNym(PaymentCode paymentCode) { + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + try { + return soroban.getPayNym(paymentCode.toString()).blockingFirst(); + } catch(Exception e) { + //ignore + } + + return null; + } + + private Wallet getExistingChildWallet(PaymentCode paymentCode) { + for(Wallet childWallet : wallet.getChildWallets()) { + if(childWallet.isBip47() && paymentCode.equals(childWallet.getKeystores().get(0).getExternalPaymentCode())) { + return childWallet; + } + } + + return null; + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java b/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java index d9ccacf0..c9b4c11c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java +++ b/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java @@ -285,7 +285,7 @@ public class Payjoin { } private int getChangeOutputIndex() { - Map changeScriptNodes = wallet.getWalletOutputScripts(KeyPurpose.CHANGE); + Map changeScriptNodes = wallet.getWalletOutputScripts(wallet.getChangeKeyPurpose()); for(int i = 0; i < psbt.getTransaction().getOutputs().size(); i++) { if(changeScriptNodes.containsKey(psbt.getTransaction().getOutputs().get(i).getScript())) { return i; diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java index 0ed6d0b6..47f19f00 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java @@ -382,7 +382,7 @@ public class CounterpartyController extends SorobanController { payNymAvatar.setPaymentCode(soroban.getPaymentCode()); payNym.setVisible(true); - claimPayNym(soroban, createMap); + claimPayNym(soroban, createMap, true); }, error -> { log.error("Error retrieving PayNym", error); Optional optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK); @@ -395,7 +395,7 @@ public class CounterpartyController extends SorobanController { } public void showPayNym(ActionEvent event) { - PayNymDialog payNymDialog = new PayNymDialog(walletId, false); + PayNymDialog payNymDialog = new PayNymDialog(walletId); payNymDialog.showAndWait(); } diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java index 7ce50e57..c2e3e0b7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java @@ -614,7 +614,7 @@ public class InitiatorController extends SorobanController { } public void findPayNym(ActionEvent event) { - PayNymDialog payNymDialog = new PayNymDialog(walletId, true); + PayNymDialog payNymDialog = new PayNymDialog(walletId, true, false); Optional optPayNym = payNymDialog.showAndWait(); optPayNym.ifPresent(payNym -> { counterpartyPayNymName.set(payNym.nymName()); diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNym.java b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNym.java index 5ec34d2d..4a8aa8de 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNym.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNym.java @@ -1,7 +1,60 @@ package com.sparrowwallet.sparrow.soroban; import com.samourai.wallet.bip47.rpc.PaymentCode; +import com.sparrowwallet.drongo.protocol.ScriptType; import java.util.List; -public record PayNym(PaymentCode paymentCode, String nymId, String nymName, boolean segwit, List following, List followers) {} +public class PayNym { + private final PaymentCode paymentCode; + private final String nymId; + private final String nymName; + private final boolean segwit; + private final List following; + private final List followers; + + public PayNym(PaymentCode paymentCode, String nymId, String nymName, boolean segwit, List following, List followers) { + this.paymentCode = paymentCode; + this.nymId = nymId; + this.nymName = nymName; + this.segwit = segwit; + this.following = following; + this.followers = followers; + } + + public PaymentCode paymentCode() { + return paymentCode; + } + + public String nymId() { + return nymId; + } + + public String nymName() { + return nymName; + } + + public boolean segwit() { + return segwit; + } + + public List following() { + return following; + } + + public List followers() { + return followers; + } + + public List getScriptTypes() { + return segwit ? getSegwitScriptTypes() : getV1ScriptTypes(); + } + + public static List getSegwitScriptTypes() { + return List.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH); + } + + public static List getV1ScriptTypes() { + return List.of(ScriptType.P2PKH); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymController.java index e563cf29..0381d343 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymController.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymController.java @@ -1,19 +1,25 @@ package com.sparrowwallet.sparrow.soroban; +import com.google.common.eventbus.Subscribe; import com.samourai.wallet.bip47.rpc.PaymentCode; import com.sparrowwallet.drongo.SecureString; +import com.sparrowwallet.drongo.bip47.SecretPoint; 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.wallet.Wallet; +import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.psbt.PSBT; +import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.*; -import com.sparrowwallet.sparrow.event.StorageEvent; -import com.sparrowwallet.sparrow.event.TimedEvent; +import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.net.ElectrumServer; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.TransactionEntry; import javafx.application.Platform; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; @@ -24,13 +30,13 @@ import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.*; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.util.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.function.UnaryOperator; import static com.sparrowwallet.sparrow.AppServices.showErrorDialog; @@ -38,7 +44,10 @@ import static com.sparrowwallet.sparrow.AppServices.showErrorDialog; public class PayNymController extends SorobanController { private static final Logger log = LoggerFactory.getLogger(PayNymController.class); + private static final long MINIMUM_P2PKH_OUTPUT_SATS = 546L; + private String walletId; + private boolean selectLinkedOnly; private PayNym walletPayNym; @FXML @@ -72,8 +81,11 @@ public class PayNymController extends SorobanController { private final StringProperty findNymProperty = new SimpleStringProperty(); - public void initializeView(String walletId) { + private final Map notificationTransactions = new HashMap<>(); + + public void initializeView(String walletId, boolean selectLinkedOnly) { this.walletId = walletId; + this.selectLinkedOnly = selectLinkedOnly; payNymName.managedProperty().bind(payNymName.visibleProperty()); payNymRetrieve.managedProperty().bind(payNymRetrieve.visibleProperty()); @@ -83,9 +95,9 @@ public class PayNymController extends SorobanController { retrievePayNymProgress.maxHeightProperty().bind(payNymName.heightProperty()); retrievePayNymProgress.setVisible(false); - Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); - if(soroban.getPaymentCode() != null) { - paymentCode.setPaymentCode(soroban.getPaymentCode()); + Wallet masterWallet = getMasterWallet(); + if(masterWallet.hasPaymentCode()) { + paymentCode.setPaymentCode(new PaymentCode(masterWallet.getPaymentCode().toString())); } findNymProperty.addListener((observable, oldValue, nymIdentifier) -> { @@ -121,6 +133,12 @@ public class PayNymController extends SorobanController { return change; }; searchPayNyms.setTextFormatter(new TextFormatter<>(paymentCodeFilter)); + searchPayNyms.addEventFilter(KeyEvent.ANY, event -> { + if(event.getCode() == KeyCode.ENTER) { + findNymProperty.set(searchPayNyms.getText()); + event.consume(); + } + }); findPayNym.managedProperty().bind(findPayNym.visibleProperty()); findPayNym.maxHeightProperty().bind(searchPayNyms.heightProperty()); findPayNym.setVisible(false); @@ -140,7 +158,7 @@ public class PayNymController extends SorobanController { followersList.setSelectionModel(new NoSelectionModel<>()); followersList.setFocusTraversable(false); - if(Config.get().isUsePayNym() && soroban.getPaymentCode() != null) { + if(Config.get().isUsePayNym() && masterWallet.hasPaymentCode()) { refresh(); } else { payNymName.setVisible(false); @@ -149,12 +167,12 @@ public class PayNymController extends SorobanController { private void refresh() { Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); - if(soroban.getPaymentCode() == null) { - throw new IllegalStateException("Payment code has not been set"); + if(!getMasterWallet().hasPaymentCode()) { + throw new IllegalStateException("Payment code is not present"); } retrievePayNymProgress.setVisible(true); - soroban.getPayNym(soroban.getPaymentCode().toString()).subscribe(payNym -> { + soroban.getPayNym(getMasterWallet().getPaymentCode().toString()).subscribe(payNym -> { retrievePayNymProgress.setVisible(false); walletPayNym = payNym; payNymName.setText(payNym.nymName()); @@ -165,6 +183,7 @@ public class PayNymController extends SorobanController { followingList.setItems(FXCollections.observableList(payNym.following())); followersList.setPlaceholder(new Label("No followers")); followersList.setItems(FXCollections.observableList(payNym.followers())); + Platform.runLater(() -> addWalletIfNotificationTransactionPresent(payNym.following())); }, error -> { retrievePayNymProgress.setVisible(false); if(error.getMessage().endsWith("404")) { @@ -215,7 +234,7 @@ public class PayNymController extends SorobanController { public void showQR(ActionEvent event) { Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); - QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(soroban.getPaymentCode().toString()); + QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(getMasterWallet().getPaymentCode().toString()); qrDisplayDialog.showAndWait(); } @@ -244,7 +263,7 @@ public class PayNymController extends SorobanController { private void makeAuthenticatedCall(PaymentCode contact) { Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); if(soroban.getHdWallet() == null) { - Wallet wallet = AppServices.get().getWallet(walletId); + Wallet wallet = getMasterWallet(); if(wallet.isEncrypted()) { Wallet copy = wallet.copy(); WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); @@ -301,10 +320,10 @@ public class PayNymController extends SorobanController { private void retrievePayNym(Soroban soroban) { soroban.createPayNym().subscribe(createMap -> { payNymName.setText((String)createMap.get("nymName")); - payNymAvatar.setPaymentCode(soroban.getPaymentCode()); + payNymAvatar.setPaymentCode(new PaymentCode(getMasterWallet().getPaymentCode().toString())); payNymName.setVisible(true); - claimPayNym(soroban, createMap); + claimPayNym(soroban, createMap, getMasterWallet().getScriptType() != ScriptType.P2PKH); refresh(); }, error -> { log.error("Error retrieving PayNym", error); @@ -340,6 +359,248 @@ public class PayNymController extends SorobanController { }); } + public boolean isLinked(PayNym payNym) { + com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString()); + return getMasterWallet().getChildWallet(externalPaymentCode, payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH) != null; + } + + private void addWalletIfNotificationTransactionPresent(List following) { + Map unlinkedPayNyms = new HashMap<>(); + Map unlinkedNotifications = new HashMap<>(); + for(PayNym payNym : following) { + if(!isLinked(payNym)) { + com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString()); + Map unlinkedNotification = getMasterWallet().getNotificationTransaction(externalPaymentCode); + if(!unlinkedNotification.isEmpty()) { + unlinkedNotifications.putAll(unlinkedNotification); + unlinkedPayNyms.put(unlinkedNotification.keySet().iterator().next(), payNym); + } + } + } + + Wallet wallet = getMasterWallet(); + if(!unlinkedNotifications.isEmpty()) { + if(wallet.isEncrypted()) { + Storage storage = AppServices.get().getOpenWallets().get(wallet); + Optional optButtonType = AppServices.showAlertDialog("Link contacts?", "Some contacts were found that may be already linked. Link these contacts? Your password is required to check.", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES); + if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) { + WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get()); + decryptWalletService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done")); + Wallet decryptedWallet = decryptWalletService.getValue(); + addWalletIfNotificationTransactionPresent(decryptedWallet, unlinkedPayNyms, unlinkedNotifications); + decryptedWallet.clearPrivate(); + }); + decryptWalletService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed")); + AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage()); + }); + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet...")); + decryptWalletService.start(); + } + } + } else { + addWalletIfNotificationTransactionPresent(wallet, unlinkedPayNyms, unlinkedNotifications); + } + } + } + + private void addWalletIfNotificationTransactionPresent(Wallet decryptedWallet, Map unlinkedPayNyms, Map unlinkedNotifications) { + for(BlockTransaction blockTransaction : unlinkedNotifications.keySet()) { + try { + PayNym payNym = unlinkedPayNyms.get(blockTransaction); + com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString()); + ECKey input0Key = decryptedWallet.getKeystores().get(0).getKey(unlinkedNotifications.get(blockTransaction)); + TransactionOutPoint input0Outpoint = com.sparrowwallet.drongo.bip47.PaymentCode.getDesignatedInput(blockTransaction.getTransaction()).getOutpoint(); + SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey()); + byte[] blindingMask = com.sparrowwallet.drongo.bip47.PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize()); + byte[] blindedPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.blind(getMasterWallet().getPaymentCode().getPayload(), blindingMask); + byte[] opReturnData = com.sparrowwallet.drongo.bip47.PaymentCode.getOpReturnData(blockTransaction.getTransaction()); + if(Arrays.equals(opReturnData, blindedPaymentCode)) { + addChildWallet(payNym, externalPaymentCode); + followingList.refresh(); + } + } catch(Exception e) { + log.error("Error adding linked contact from notification transaction", e); + } + } + } + + public void addChildWallet(PayNym payNym, com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode) { + Wallet masterWallet = getMasterWallet(); + Storage storage = AppServices.get().getOpenWallets().get(masterWallet); + List scriptTypes = masterWallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes(); + for(ScriptType childScriptType : scriptTypes) { + Wallet addedWallet = masterWallet.addChildWallet(externalPaymentCode, childScriptType); + addedWallet.setLabel(payNym.nymName() + " " + childScriptType.getName()); + if(!storage.isPersisted(addedWallet)) { + try { + storage.saveWallet(addedWallet); + } catch(Exception e) { + log.error("Error saving wallet", e); + AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage()); + } + } + EventManager.get().post(new ChildWalletAddedEvent(storage, masterWallet, addedWallet)); + } + } + + public void linkPayNym(PayNym payNym) { + Optional optButtonType = AppServices.showAlertDialog("Link PayNym?", + "Linking to this contact will allow you to send to it non-collaboratively through unique private addresses you can generate independently.\n\n" + + "It will cost " + MINIMUM_P2PKH_OUTPUT_SATS + " sats to create the link, plus the mining fee. Send transaction?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES); + if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) { + broadcastNotificationTransaction(payNym); + } else { + followingList.refresh(); + } + } + + public void broadcastNotificationTransaction(PayNym payNym) { + Wallet masterWallet = getMasterWallet(); + WalletTransaction walletTransaction; + try { + walletTransaction = getWalletTransaction(masterWallet, payNym, new byte[80], null); + } catch(InsufficientFundsException e) { + try { + Wallet wallet = AppServices.get().getWallet(walletId); + walletTransaction = getWalletTransaction(wallet, payNym, new byte[80], null); + } catch(InsufficientFundsException e2) { + AppServices.showErrorDialog("Insufficient Funds", "There are not enough funds in this wallet to broadcast the notification transaction."); + followingList.refresh(); + return; + } + } + + final WalletTransaction walletTx = walletTransaction; + final com.sparrowwallet.drongo.bip47.PaymentCode paymentCode = masterWallet.getPaymentCode(); + Wallet wallet = walletTransaction.getWallet(); + Storage storage = AppServices.get().getOpenWallets().get(wallet); + if(wallet.isEncrypted()) { + WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get()); + decryptWalletService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done")); + Wallet decryptedWallet = decryptWalletService.getValue(); + broadcastNotificationTransaction(decryptedWallet, walletTx, paymentCode, payNym); + decryptedWallet.clearPrivate(); + }); + decryptWalletService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed")); + followingList.refresh(); + AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage()); + }); + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet...")); + decryptWalletService.start(); + } + } else { + broadcastNotificationTransaction(wallet, walletTx, paymentCode, payNym); + } + } + + private void broadcastNotificationTransaction(Wallet decryptedWallet, WalletTransaction walletTransaction, com.sparrowwallet.drongo.bip47.PaymentCode paymentCode, PayNym payNym) { + try { + com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString()); + WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue(); + ECKey input0Key = decryptedWallet.getKeystores().get(0).getKey(input0Node); + TransactionOutPoint input0Outpoint = walletTransaction.getTransaction().getInputs().iterator().next().getOutpoint(); + SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey()); + byte[] blindingMask = com.sparrowwallet.drongo.bip47.PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize()); + byte[] blindedPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.blind(paymentCode.getPayload(), blindingMask); + + WalletTransaction finalWalletTx = getWalletTransaction(decryptedWallet, payNym, blindedPaymentCode, walletTransaction.getSelectedUtxos().keySet()); + PSBT psbt = finalWalletTx.createPSBT(); + decryptedWallet.sign(psbt); + decryptedWallet.finalise(psbt); + Transaction transaction = psbt.extractTransaction(); + + ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(transaction); + broadcastTransactionService.setOnSucceeded(successEvent -> { + ElectrumServer.TransactionMempoolService transactionMempoolService = new ElectrumServer.TransactionMempoolService(walletTransaction.getWallet(), transaction.getTxId(), new HashSet<>(walletTransaction.getSelectedUtxos().values())); + transactionMempoolService.setDelay(Duration.seconds(2)); + transactionMempoolService.setPeriod(Duration.seconds(5)); + transactionMempoolService.setRestartOnFailure(false); + transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> { + Set scriptHashes = transactionMempoolService.getValue(); + if(!scriptHashes.isEmpty()) { + transactionMempoolService.cancel(); + addChildWallet(payNym, externalPaymentCode); + retrievePayNymProgress.setVisible(false); + followingList.refresh(); + + BlockTransaction blockTransaction = walletTransaction.getWallet().getTransactions().get(transaction.getTxId()); + if(blockTransaction != null && blockTransaction.getLabel() == null) { + blockTransaction.setLabel("Link " + payNym.nymName()); + TransactionEntry transactionEntry = new TransactionEntry(walletTransaction.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()); + EventManager.get().post(new WalletEntryLabelsChangedEvent(walletTransaction.getWallet(), List.of(transactionEntry))); + } + } + + if(transactionMempoolService.getIterationCount() > 5 && transactionMempoolService.isRunning()) { + transactionMempoolService.cancel(); + retrievePayNymProgress.setVisible(false); + followingList.refresh(); + log.error("Timeout searching for broadcasted notification transaction"); + AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try linking again."); + } + }); + transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> { + transactionMempoolService.cancel(); + log.error("Error searching for broadcasted notification transaction", mempoolWorkerStateEvent.getSource().getException()); + retrievePayNymProgress.setVisible(false); + followingList.refresh(); + AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try linking again."); + }); + transactionMempoolService.start(); + }); + broadcastTransactionService.setOnFailed(failedEvent -> { + log.error("Error broadcasting notification transaction", failedEvent.getSource().getException()); + retrievePayNymProgress.setVisible(false); + followingList.refresh(); + AppServices.showErrorDialog("Error broadcasting notification transaction", failedEvent.getSource().getException().getMessage()); + }); + retrievePayNymProgress.setVisible(true); + notificationTransactions.put(transaction.getTxId(), payNym); + broadcastTransactionService.start(); + } catch(Exception e) { + log.error("Error creating notification transaction", e); + retrievePayNymProgress.setVisible(false); + followingList.refresh(); + AppServices.showErrorDialog("Error creating notification transaction", e.getMessage()); + } + } + + private WalletTransaction getWalletTransaction(Wallet wallet, PayNym payNym, byte[] blindedPaymentCode, Collection utxos) throws InsufficientFundsException { + com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString()); + Payment payment = new Payment(externalPaymentCode.getNotificationAddress(), "Link " + payNym.nymName(), MINIMUM_P2PKH_OUTPUT_SATS, false); + List payments = List.of(payment); + List opReturns = List.of(blindedPaymentCode); + Double feeRate = AppServices.getDefaultFeeRate(); + Double minimumFeeRate = AppServices.getMinimumFeeRate(); + boolean groupByAddress = Config.get().isGroupByAddress(); + boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs(); + + long noInputsFee = getMasterWallet().getNoInputsFee(payments, feeRate); + List utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true)); + List utxoFilters = List.of(new FrozenUtxoFilter(), new CoinbaseUtxoFilter(wallet)); + + return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, false); + } + + private Wallet getMasterWallet() { + Wallet wallet = AppServices.get().getWallet(walletId); + return wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); + } + + public boolean isSelectLinkedOnly() { + return selectLinkedOnly; + } + public PayNym getPayNym() { return payNymProperty.get(); } @@ -348,6 +609,22 @@ public class PayNymController extends SorobanController { return payNymProperty; } + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + List changedLabelEntries = new ArrayList<>(); + for(Map.Entry notificationTx : notificationTransactions.entrySet()) { + BlockTransaction blockTransaction = event.getWallet().getTransactions().get(notificationTx.getKey()); + if(blockTransaction != null && blockTransaction.getLabel() == null) { + blockTransaction.setLabel("Link " + notificationTx.getValue().nymName()); + changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap())); + } + } + + if(!changedLabelEntries.isEmpty()) { + Platform.runLater(() -> EventManager.get().post(new WalletEntryLabelsChangedEvent(event.getWallet(), changedLabelEntries))); + } + } + public static class NoSelectionModel extends MultipleSelectionModel { @Override diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymDialog.java b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymDialog.java index c376897b..de76daa9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymDialog.java @@ -1,13 +1,18 @@ package com.sparrowwallet.sparrow.soroban; import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; import javafx.fxml.FXMLLoader; import javafx.scene.control.*; import java.io.IOException; public class PayNymDialog extends Dialog { - public PayNymDialog(String walletId, boolean selectPayNym) { + public PayNymDialog(String walletId) { + this(walletId, false, false); + } + + public PayNymDialog(String walletId, boolean selectPayNym, boolean selectLinkedOnly) { final DialogPane dialogPane = getDialogPane(); AppServices.setStageIcon(dialogPane.getScene().getWindow()); AppServices.onEscapePressed(dialogPane.getScene(), this::close); @@ -16,7 +21,9 @@ public class PayNymDialog extends Dialog { FXMLLoader payNymLoader = new FXMLLoader(AppServices.class.getResource("soroban/paynym.fxml")); dialogPane.setContent(payNymLoader.load()); PayNymController payNymController = payNymLoader.getController(); - payNymController.initializeView(walletId); + payNymController.initializeView(walletId, selectLinkedOnly); + + EventManager.get().register(payNymController); dialogPane.setPrefWidth(730); dialogPane.setPrefHeight(600); @@ -35,12 +42,16 @@ public class PayNymDialog extends Dialog { selectButton.setDisable(true); selectButton.setDefaultButton(true); payNymController.payNymProperty().addListener((observable, oldValue, payNym) -> { - selectButton.setDisable(payNym == null); + selectButton.setDisable(payNym == null || (selectLinkedOnly && !payNymController.isLinked(payNym))); }); } else { dialogPane.getButtonTypes().add(doneButtonType); } + setOnCloseRequest(event -> { + EventManager.get().unregister(payNymController); + }); + setResultConverter(dialogButton -> dialogButton == selectButtonType ? payNymController.getPayNym() : null); } catch(IOException e) { throw new RuntimeException(e); diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymService.java b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymService.java index 21943ad6..0d1dc792 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymService.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/PayNymService.java @@ -74,14 +74,14 @@ public class PayNymService { .map(Optional::get); } - public Observable> addSamouraiPaymentCode(PaymentCode paymentCode, String authToken, String signature) { + public Observable> addPaymentCode(PaymentCode paymentCode, String authToken, String signature, boolean segwit) { Map headers = new HashMap<>(); headers.put("content-type", "application/json"); headers.put("auth-token", authToken); HashMap body = new HashMap<>(); body.put("nym", paymentCode.toString()); - body.put("code", paymentCode.makeSamouraiPaymentCode()); + body.put("code", segwit ? paymentCode.makeSamouraiPaymentCode() : paymentCode.toString()); body.put("signature", signature); IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST); diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java b/src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java index 3dfbea37..a8de1d3d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java @@ -73,7 +73,7 @@ public class Soroban { String passphrase = keystore.getSeed().getPassphrase().asString(); byte[] seed = hdWalletFactory.computeSeedFromWords(words); BIP47Wallet bip47Wallet = hdWalletFactory.getBIP47(Utils.bytesToHex(seed), passphrase, sorobanServer.getParams()); - paymentCode = bip47Util.getPaymentCode(bip47Wallet); + paymentCode = bip47Util.getPaymentCode(bip47Wallet, wallet.isMasterWallet() ? wallet.getAccountIndex() : wallet.getMasterWallet().getAccountIndex()); } catch(Exception e) { throw new IllegalStateException("Could not create payment code", e); } @@ -93,7 +93,7 @@ public class Soroban { byte[] seed = hdWalletFactory.computeSeedFromWords(words); hdWallet = new HD_Wallet(purpose, new ArrayList<>(words), sorobanServer.getParams(), seed, passphrase); bip47Wallet = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams()); - paymentCode = bip47Util.getPaymentCode(bip47Wallet); + paymentCode = bip47Util.getPaymentCode(bip47Wallet, wallet.isMasterWallet() ? wallet.getAccountIndex() : wallet.getMasterWallet().getAccountIndex()); } catch(Exception e) { throw new IllegalStateException("Could not create Soroban HD wallet ", e); } @@ -160,8 +160,8 @@ public class Soroban { return payNymService.claimPayNym(authToken, signature); } - public Observable> addSamouraiPaymentCode(String authToken, String signature) { - return payNymService.addSamouraiPaymentCode(paymentCode, authToken, signature); + public Observable> addPaymentCode(String authToken, String signature, boolean segwit) { + return payNymService.addPaymentCode(paymentCode, authToken, signature, segwit); } public Observable> followPaymentCode(PaymentCode paymentCode, String authToken, String signature) { diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java index 7fb44d47..7f7c109d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java @@ -23,13 +23,13 @@ public class SorobanController { private static final Logger log = LoggerFactory.getLogger(SorobanController.class); protected static final Pattern PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9a-fA-F][0-9a-fA-F]"); - protected void claimPayNym(Soroban soroban, Map createMap) { + protected void claimPayNym(Soroban soroban, Map createMap, boolean segwit) { if(createMap.get("claimed") == Boolean.FALSE) { soroban.getAuthToken(createMap).subscribe(authToken -> { String signature = soroban.getSignature(authToken); soroban.claimPayNym(authToken, signature).subscribe(claimMap -> { log.debug("Claimed payment code " + claimMap.get("claimed")); - soroban.addSamouraiPaymentCode(authToken, signature).subscribe(addMap -> { + soroban.addPaymentCode(authToken, signature, segwit).subscribe(addMap -> { log.debug("Added payment code " + addMap); }); }, error -> { @@ -37,7 +37,7 @@ public class SorobanController { String newSignature = soroban.getSignature(newAuthToken); soroban.claimPayNym(newAuthToken, newSignature).subscribe(claimMap -> { log.debug("Claimed payment code " + claimMap.get("claimed")); - soroban.addSamouraiPaymentCode(newAuthToken, newSignature).subscribe(addMap -> { + soroban.addPaymentCode(newAuthToken, newSignature, segwit).subscribe(addMap -> { log.debug("Added payment code " + addMap); }); }, newError -> { diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 95bdf9bb..35050e3b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -579,7 +579,7 @@ public class HeadersController extends TransactionFormController implements Init List payments = new ArrayList<>(); Map changeMap = new LinkedHashMap<>(); - Map changeOutputScripts = wallet.getWalletOutputScripts(KeyPurpose.CHANGE); + Map changeOutputScripts = wallet.getWalletOutputScripts(wallet.getChangeKeyPurpose()); for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) { WalletNode changeNode = changeOutputScripts.get(txOutput.getScript()); if(changeNode != null) { @@ -729,9 +729,10 @@ public class HeadersController extends TransactionFormController implements Init private void initializeSignButton(Wallet signingWallet) { Optional softwareKeystore = signingWallet.getKeystores().stream().filter(keystore -> keystore.getSource().equals(KeystoreSource.SW_SEED)).findAny(); Optional usbKeystore = signingWallet.getKeystores().stream().filter(keystore -> keystore.getSource().equals(KeystoreSource.HW_USB)).findAny(); - if(softwareKeystore.isEmpty() && usbKeystore.isEmpty()) { + Optional bip47Keystore = signingWallet.getKeystores().stream().filter(keystore -> keystore.getSource().equals(KeystoreSource.SW_PAYMENT_CODE)).findAny(); + if(softwareKeystore.isEmpty() && usbKeystore.isEmpty() && bip47Keystore.isEmpty()) { signButton.setDisable(true); - } else if(softwareKeystore.isEmpty()) { + } else if(softwareKeystore.isEmpty() && bip47Keystore.isEmpty()) { Glyph usbGlyph = new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB); usbGlyph.setFontSize(20); signButton.setGraphic(usbGlyph); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java index 155fa0c6..55e229c2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java @@ -105,12 +105,12 @@ public class OutputController extends TransactionFormController implements Initi private void updateOutputLegendFromWallet(TransactionOutput txOutput, Wallet signingWallet) { String baseText = getLegendText(txOutput); if(signingWallet != null) { - if(outputForm.isWalletConsolidation()) { - outputFieldset.setText(baseText + " - Consolidation"); - outputFieldset.setIcon(TransactionDiagram.getConsolidationGlyph()); - } else if(outputForm.isWalletChange()) { + if(outputForm.isWalletChange()) { outputFieldset.setText(baseText + " - Change"); outputFieldset.setIcon(TransactionDiagram.getChangeGlyph()); + } else if(outputForm.isWalletConsolidation()) { + outputFieldset.setText(baseText + " - Consolidation"); + outputFieldset.setIcon(TransactionDiagram.getConsolidationGlyph()); } else { outputFieldset.setText(baseText + " - Payment"); outputFieldset.setIcon(TransactionDiagram.getPaymentGlyph()); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java index 33861cf3..184d83c5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java @@ -36,7 +36,7 @@ public class OutputForm extends IndexedTransactionForm { } public boolean isWalletChange() { - return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(getTransactionOutput().getScript())); + return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(getSigningWallet().getChangeKeyPurpose()).containsKey(getTransactionOutput().getScript())); } public boolean isWalletPayment() { diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java index 46beb7b1..baa258da 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java @@ -157,10 +157,10 @@ public class TransactionController implements Initializable { } if(form instanceof OutputForm) { OutputForm outputForm = (OutputForm)form; - if(outputForm.isWalletConsolidation()) { - setGraphic(TransactionDiagram.getConsolidationGlyph()); - } else if(outputForm.isWalletChange()) { + if(outputForm.isWalletChange()) { setGraphic(TransactionDiagram.getChangeGlyph()); + } else if(outputForm.isWalletConsolidation()) { + setGraphic(TransactionDiagram.getConsolidationGlyph()); } else { setGraphic(TransactionDiagram.getPaymentGlyph()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java index 65ad1c72..ce75833b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java @@ -336,6 +336,7 @@ public class KeystoreController extends WalletFormController implements Initiali keystore.setExtendedPublicKey(importedKeystore.getExtendedPublicKey()); keystore.setMasterPrivateExtendedKey(importedKeystore.getMasterPrivateExtendedKey()); keystore.setSeed(importedKeystore.getSeed()); + keystore.setBip47ExtendedPrivateKey(importedKeystore.getBip47ExtendedPrivateKey()); updateType(); label.setText(keystore.getLabel()); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java index 710d18a1..c0f840e7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -6,6 +6,10 @@ import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.address.P2PKHAddress; +import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException; +import com.sparrowwallet.drongo.bip47.PaymentCode; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.protocol.TransactionOutput; import com.sparrowwallet.drongo.uri.BitcoinURI; @@ -20,10 +24,7 @@ import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent; import com.sparrowwallet.sparrow.event.OpenWalletsEvent; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.net.ExchangeSource; -import com.sparrowwallet.sparrow.soroban.PayNym; -import com.sparrowwallet.sparrow.soroban.PayNymAddress; -import com.sparrowwallet.sparrow.soroban.PayNymDialog; -import com.sparrowwallet.sparrow.soroban.SorobanServices; +import com.sparrowwallet.sparrow.soroban.*; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; @@ -119,6 +120,8 @@ public class PaymentController extends WalletFormController implements Initializ emptyAmountProperty.set(true); } + updateMixOnlyStatus(); + sendController.updateTransaction(); } }; @@ -159,7 +162,8 @@ public class PaymentController extends WalletFormController implements Initializ openWallets.prefWidthProperty().bind(address.widthProperty()); openWallets.valueProperty().addListener((observable, oldValue, newValue) -> { if(newValue == payNymWallet) { - PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true); + boolean selectLinkedOnly = sendController.getPaymentTabs().getTabs().size() > 1 || !SorobanServices.canWalletMix(sendController.getWalletForm().getWallet()); + PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true, selectLinkedOnly); Optional optPayNym = payNymDialog.showAndWait(); if(optPayNym.isPresent()) { PayNym payNym = optPayNym.get(); @@ -175,11 +179,8 @@ public class PaymentController extends WalletFormController implements Initializ } }); - payNymProperty.addListener((observable, oldValue, newValue) -> { - addPaymentButton.setDisable(newValue != null); - if(newValue != null) { - sendController.setPayNymPayment(); - } + payNymProperty.addListener((observable, oldValue, payNym) -> { + updateMixOnlyStatus(payNym); revalidateAmount(); }); @@ -249,14 +250,32 @@ public class PaymentController extends WalletFormController implements Initializ addValidation(validationSupport); } + public void updateMixOnlyStatus() { + updateMixOnlyStatus(payNymProperty.get()); + } + + public void updateMixOnlyStatus(PayNym payNym) { + boolean mixOnly = false; + try { + mixOnly = payNym != null && getRecipientAddress() instanceof PayNymAddress; + } catch(InvalidAddressException e) { + log.error("Error creating payment code from PayNym", e); + } + + addPaymentButton.setDisable(mixOnly); + if(mixOnly) { + sendController.setPayNymMixOnlyPayment(); + } + } + private void updateOpenWallets() { updateOpenWallets(AppServices.get().getOpenWallets().keySet()); } private void updateOpenWallets(Collection wallets) { - List openWalletList = wallets.stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet()).collect(Collectors.toList()); + List openWalletList = wallets.stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet() && !wallet.isBip47()).collect(Collectors.toList()); - if(sendController.getPaymentTabs().getTabs().size() <= 1 && SorobanServices.canWalletMix(sendController.getWalletForm().getWallet())) { + if(sendController.getWalletForm().getWallet().hasPaymentCode()) { openWalletList.add(payNymWallet); } @@ -296,7 +315,30 @@ public class PaymentController extends WalletFormController implements Initializ } private Address getRecipientAddress() throws InvalidAddressException { - return payNymProperty.get() == null ? Address.fromString(address.getText()) : new PayNymAddress(payNymProperty.get()); + if(payNymProperty.get() == null) { + return Address.fromString(address.getText()); + } + + try { + Wallet recipientBip47Wallet = getWalletForPayNym(payNymProperty.get()); + if(recipientBip47Wallet != null) { + WalletNode sendNode = recipientBip47Wallet.getFreshNode(KeyPurpose.SEND); + ECKey pubKey = recipientBip47Wallet.getPubKey(sendNode); + Address address = recipientBip47Wallet.getScriptType().getAddress(pubKey); + if(sendController.getPaymentTabs().getTabs().size() > 1 || (getRecipientValueSats() != null && getRecipientValueSats() > getRecipientDustThreshold(address))) { + return address; + } + } + } catch(InvalidPaymentCodeException e) { + log.error("Error creating payment code from PayNym", e); + } + + return new PayNymAddress(payNymProperty.get()); + } + + private Wallet getWalletForPayNym(PayNym payNym) throws InvalidPaymentCodeException { + Wallet masterWallet = sendController.getWalletForm().getMasterWallet(); + return masterWallet.getChildWallet(new PaymentCode(payNym.paymentCode().toString()), payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH); } private Long getRecipientValueSats() { @@ -321,10 +363,6 @@ public class PaymentController extends WalletFormController implements Initializ } private long getRecipientDustThreshold() { - if(payNymProperty.get() != null) { - return 0; - } - Address address; try { address = getRecipientAddress(); @@ -332,6 +370,14 @@ public class PaymentController extends WalletFormController implements Initializ address = new P2PKHAddress(new byte[20]); } + if(address instanceof PayNymAddress && SorobanServices.canWalletMix(sendController.getWalletForm().getWallet())) { + return 0; + } + + return getRecipientDustThreshold(address); + } + + private long getRecipientDustThreshold(Address address) { TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript()); return address.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE); } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index 5b7715b9..ef2dc3fb 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -248,7 +248,10 @@ public class SendController extends WalletFormController implements Initializabl if(!paymentTabs.getStyleClass().contains("multiple-tabs")) { paymentTabs.getStyleClass().add("multiple-tabs"); } - paymentTabs.getTabs().forEach(tab -> tab.setClosable(true)); + paymentTabs.getTabs().forEach(tab -> { + tab.setClosable(true); + ((PaymentController)tab.getUserData()).updateMixOnlyStatus(); + }); } else { paymentTabs.getStyleClass().remove("multiple-tabs"); Tab remainingTab = paymentTabs.getTabs().get(0); @@ -392,7 +395,7 @@ public class SendController extends WalletFormController implements Initializabl transactionDiagram.update(walletTransaction); updatePrivacyAnalysis(walletTransaction); - createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymPayment(walletTransaction.getPayments())); + createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymMixOnlyPayment(walletTransaction.getPayments())); }); transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> { @@ -949,11 +952,11 @@ public class SendController extends WalletFormController implements Initializabl } } - private boolean isPayNymPayment(List payments) { + private boolean isPayNymMixOnlyPayment(List payments) { return payments.size() == 1 && payments.get(0).getAddress() instanceof PayNymAddress; } - public void setPayNymPayment() { + public void setPayNymMixOnlyPayment() { optimizationToggleGroup.selectToggle(privacyToggle); transactionDiagram.setOptimizationStrategy(OptimizationStrategy.PRIVACY); efficiencyToggle.setDisable(true); @@ -967,8 +970,8 @@ public class SendController extends WalletFormController implements Initializabl } private void updateOptimizationButtons(List payments) { - if(isPayNymPayment(payments)) { - setPayNymPayment(); + if(isPayNymMixOnlyPayment(payments)) { + setPayNymMixOnlyPayment(); } else if(isMixPossible(payments)) { setPreferredOptimizationStrategy(); efficiencyToggle.setDisable(false); @@ -1422,7 +1425,7 @@ public class SendController extends WalletFormController implements Initializabl List payments = walletTransaction.getPayments(); List userPayments = payments.stream().filter(payment -> payment.getType() != Payment.Type.FAKE_MIX).collect(Collectors.toList()); OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy(); - boolean payNymPresent = isPayNymPayment(payments); + boolean payNymPresent = isPayNymMixOnlyPayment(payments); boolean fakeMixPresent = payments.stream().anyMatch(payment -> payment.getType() == Payment.Type.FAKE_MIX); boolean roundPaymentAmounts = userPayments.stream().anyMatch(payment -> payment.getAmount() % 100 == 0); boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType()); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java index c7ca7c91..86a9775f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java @@ -24,6 +24,7 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.util.*; +import java.util.stream.Collectors; import static com.sparrowwallet.drongo.wallet.WalletNode.nodeRangesToString; @@ -126,37 +127,62 @@ public class WalletForm { log.debug(nodes == null ? wallet.getFullName() + " refreshing full wallet history" : wallet.getFullName() + " requesting node wallet history for " + nodeRangesToString(nodes)); } - ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, getWalletTransactionNodes(nodes)); - historyService.setOnSucceeded(workerStateEvent -> { - if(historyService.getValue()) { - EventManager.get().post(new WalletHistoryFinishedEvent(wallet)); - updateWallet(blockHeight, previousWallet); - } - }); - historyService.setOnFailed(workerStateEvent -> { - if(workerStateEvent.getSource().getException() instanceof AllHistoryChangedException) { - try { - storage.backupWallet(); - } catch(IOException e) { - log.error("Error backing up wallet", e); + Set walletTransactionNodes = getWalletTransactionNodes(nodes); + if(walletTransactionNodes == null || !walletTransactionNodes.isEmpty()) { + ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, walletTransactionNodes); + historyService.setOnSucceeded(workerStateEvent -> { + if(historyService.getValue()) { + EventManager.get().post(new WalletHistoryFinishedEvent(wallet)); + updateWallet(blockHeight, previousWallet); } + }); + historyService.setOnFailed(workerStateEvent -> { + if(workerStateEvent.getSource().getException() instanceof AllHistoryChangedException) { + try { + storage.backupWallet(); + } catch(IOException e) { + log.error("Error backing up wallet", e); + } - wallet.clearHistory(); - AppServices.clearTransactionHistoryCache(wallet); - EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId())); - } else { - if(AppServices.isConnected()) { - log.error("Error retrieving wallet history", workerStateEvent.getSource().getException()); + wallet.clearHistory(); + AppServices.clearTransactionHistoryCache(wallet); + EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId())); } else { - log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException()); + if(AppServices.isConnected()) { + log.error("Error retrieving wallet history", workerStateEvent.getSource().getException()); + } else { + log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException()); + } + + EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException())); } + }); - EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException())); - } - }); + EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes)); + historyService.start(); + } - EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes)); - historyService.start(); + if(wallet.isMasterWallet() && wallet.hasPaymentCode() && refreshNotificationNode(nodes)) { + ElectrumServer.PaymentCodesService paymentCodesService = new ElectrumServer.PaymentCodesService(getWalletId(), wallet); + paymentCodesService.setOnSucceeded(successEvent -> { + List addedWallets = paymentCodesService.getValue(); + for(Wallet addedWallet : addedWallets) { + if(!storage.isPersisted(addedWallet)) { + try { + storage.saveWallet(addedWallet); + } catch(Exception e) { + log.error("Error saving wallet", e); + AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage()); + } + } + EventManager.get().post(new ChildWalletAddedEvent(storage, wallet, addedWallet)); + } + }); + paymentCodesService.setOnFailed(failedEvent -> { + log.error("Could not determine payment codes for wallet " + wallet.getFullName(), failedEvent.getSource().getException()); + }); + paymentCodesService.start(); + } } } @@ -226,7 +252,20 @@ public class WalletForm { } } - return allNodes.isEmpty() ? walletNodes : allNodes; + Set nodes = allNodes.isEmpty() ? walletNodes : allNodes; + if(nodes.stream().anyMatch(node -> node.getDerivation().size() == 1)) { + return nodes.stream().filter(node -> node.getDerivation().size() > 1).collect(Collectors.toSet()); + } + + return nodes; + } + + public boolean refreshNotificationNode(Set walletNodes) { + if(walletNodes == null) { + return true; + } + + return walletNodes.stream().anyMatch(node -> node.getDerivation().size() == 1); } public WalletTransaction getCreatedWalletTransaction() { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java index c3e24bd5..7f76c1d1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java @@ -100,8 +100,9 @@ public class WalletTransactionsEntry extends Entry { private static Collection getWalletTransactions(Wallet wallet) { Map walletTransactionMap = new HashMap<>(wallet.getTransactions().size()); - getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(KeyPurpose.RECEIVE)); - getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(KeyPurpose.CHANGE)); + for(KeyPurpose keyPurpose : wallet.getWalletKeyPurposes()) { + getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(keyPurpose)); + } List walletTransactions = new ArrayList<>(walletTransactionMap.values()); Collections.sort(walletTransactions); diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java index 0e1c0474..e4b63f8c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java @@ -9,6 +9,7 @@ import com.samourai.whirlpool.client.wallet.WhirlpoolWalletService; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,7 +39,7 @@ public class SparrowPostmixHandler implements IPostmixHandler { int index = Math.max(getIndexHandler().getAndIncrementUnconfirmed(), startIndex); // address - Address address = wallet.getAddress(keyPurpose, index); + Address address = wallet.getAddress(new WalletNode(keyPurpose, index)); String path = XPubUtil.getInstance().getPath(index, keyPurpose.getPathIndex().num()); log.info("Mixing to external xPub -> receiveAddress=" + address + ", path=" + path); diff --git a/src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.css b/src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.css index 33de8478..b16f92c1 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.css +++ b/src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.css @@ -55,6 +55,14 @@ -fx-padding: 10 0 10 0; } +#followingList .paynym-cell.unlinked .label { + -fx-text-fill: #a0a1a7; +} + +#followingList .paynym-cell .button .label.glyph-font { + -fx-text-fill: -fx-text-base-color; +} + #followersList .paynym-cell .label { -fx-text-fill: #a0a1a7; } \ No newline at end of file diff --git a/src/main/resources/com/sparrowwallet/sparrow/sql/V6__PaymentCode.sql b/src/main/resources/com/sparrowwallet/sparrow/sql/V6__PaymentCode.sql new file mode 100644 index 00000000..4fd5ecc3 --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/sql/V6__PaymentCode.sql @@ -0,0 +1 @@ +alter table keystore add column externalPaymentCode varchar(255) after extendedPublicKey; \ No newline at end of file