From 17a04510fdd5022cededcfce4a81bb06fb14a2c3 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Wed, 22 Apr 2026 14:57:30 +0200 Subject: [PATCH] add initial support for sp wallets --- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 2 +- .../sparrowwallet/sparrow/AppServices.java | 2 + .../sparrowwallet/sparrow/BaseController.java | 3 +- .../sparrow/control/AddAccountDialog.java | 3 +- .../sparrow/control/AddressTreeTable.java | 1 + .../sparrow/control/DescriptorArea.java | 13 +- .../sparrow/control/EntryCell.java | 3 +- .../sparrow/control/FileWalletExportPane.java | 8 +- .../control/PrivateKeySweepDialog.java | 60 +++++++-- .../sparrow/control/QRScanDialog.java | 22 +++- .../sparrow/control/WalletExportDialog.java | 8 +- .../sparrow/event/SettingsChangedEvent.java | 2 +- .../sparrowwallet/sparrow/io/Descriptor.java | 44 ++++--- .../com/sparrowwallet/sparrow/io/Storage.java | 4 +- .../sparrow/io/db/KeystoreDao.java | 2 +- .../sparrow/io/db/WalletDao.java | 2 +- .../sparrow/io/db/WalletNodeMapper.java | 2 +- .../terminal/wallet/SettingsDialog.java | 2 +- .../sparrow/wallet/KeystoreController.java | 85 +++++++++++-- .../sparrow/wallet/PaymentController.java | 19 +-- .../sparrow/wallet/SettingsController.java | 115 +++++++++++++----- .../sparrowwallet/sparrow/wallet/keystore.css | 2 +- .../sparrow/wallet/keystore.fxml | 11 +- .../sparrow/wallet/settings.fxml | 8 +- 25 files changed, 314 insertions(+), 111 deletions(-) diff --git a/drongo b/drongo index 15e5aaa4..bc526c90 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 15e5aaa4b9846c7d410723a7785198c62ef66248 +Subproject commit bc526c90e1a4fa2988df1538d6925ea7fdceb5c3 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 3f972f69..053418ca 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -1473,7 +1473,7 @@ public class AppController implements Initializable { WalletForm selectedWalletForm = getSelectedWalletForm(); if(selectedWalletForm != null) { Wallet wallet = selectedWalletForm.getWallet(); - if(wallet.getKeystores().size() == 1) { + if(wallet.getPolicyType() == PolicyType.SINGLE_HD) { //Can sign and verify messageSignDialog = new MessageSignDialog(wallet); } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index b0a111b9..b5f2df97 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -1212,6 +1212,7 @@ public class AppServices { public static boolean isWhirlpoolCompatible(Wallet wallet) { return WHIRLPOOL_NETWORKS.contains(Network.get()) + && wallet.getPolicyType() == PolicyType.SINGLE_HD && wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported && wallet.getKeystores().size() == 1 && wallet.getKeystores().get(0).hasSeed() @@ -1222,6 +1223,7 @@ public class AppServices { public static boolean isWhirlpoolPostmixCompatible(Wallet wallet) { return WHIRLPOOL_NETWORKS.contains(Network.get()) + && wallet.getPolicyType() == PolicyType.SINGLE_HD && wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported && wallet.getKeystores().size() == 1 && wallet.getKeystores().getFirst().getWalletModel() != WalletModel.BITBOX_02; //BitBox02 does not support high account numbers diff --git a/src/main/java/com/sparrowwallet/sparrow/BaseController.java b/src/main/java/com/sparrowwallet/sparrow/BaseController.java index cee2d979..29e33e0d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/BaseController.java +++ b/src/main/java/com/sparrowwallet/sparrow/BaseController.java @@ -55,8 +55,7 @@ public abstract class BaseController { descriptorArea.setMouseOverTextDelay(Duration.ofMillis(150)); descriptorArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> { TwoDimensional.Position position = descriptorArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward); - int index = descriptorArea.getWallet().getPolicyType() == PolicyType.SINGLE || descriptorArea.getWallet().getPolicyType() == PolicyType.SINGLE_SILENT_PAYMENTS ? - position.getMajor() - 1 : ((position.getMajor() - 1) / 2); + int index = descriptorArea.getWallet().getPolicyType() == PolicyType.SINGLE_HD || descriptorArea.getWallet().getPolicyType() == PolicyType.SINGLE_SP ? position.getMajor() - 1 : ((position.getMajor() - 1) / 2); if(position.getMajor() > 0 && index >= 0 && index < descriptorArea.getWallet().getKeystores().size()) { Keystore hoverKeystore = descriptorArea.getWallet().getKeystores().get(index); Point2D pos = e.getScreenPosition(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java index fab81a57..964debb2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java @@ -1,5 +1,6 @@ package com.sparrowwallet.sparrow.control; +import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.drongo.wallet.StandardAccount; import com.sparrowwallet.drongo.wallet.Wallet; @@ -68,7 +69,7 @@ public class AddAccountDialog extends Dialog> { final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT); if(!availableAccounts.isEmpty() && (masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED) - || (masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) { + || (masterWallet.getPolicyType() == PolicyType.SINGLE_HD && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) { dialogPane.getButtonTypes().add(discoverButtonType); Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType); discoverButton.disableProperty().bind(AppServices.onlineProperty().not()); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java b/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java index 9f9ab8ff..332ef7d9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java @@ -80,6 +80,7 @@ public class AddressTreeTable extends CoinTreeTable { contextMenu.getItems().add(showCountItem); getColumns().forEach(col -> col.setContextMenu(contextMenu)); + setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet())); setEditable(true); setupColumnWidths(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DescriptorArea.java b/src/main/java/com/sparrowwallet/sparrow/control/DescriptorArea.java index fb3a7f0b..f934632a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DescriptorArea.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DescriptorArea.java @@ -14,8 +14,7 @@ import org.fxmisc.richtext.CodeArea; import java.util.List; -import static com.sparrowwallet.drongo.policy.PolicyType.MULTI; -import static com.sparrowwallet.drongo.policy.PolicyType.SINGLE; +import static com.sparrowwallet.drongo.policy.PolicyType.*; import static com.sparrowwallet.drongo.protocol.ScriptType.MULTISIG; public class DescriptorArea extends CodeArea { @@ -33,13 +32,13 @@ public class DescriptorArea extends CodeArea { List keystores = wallet.getKeystores(); int threshold = wallet.getDefaultPolicy().getNumSignaturesRequired(); - if(SINGLE.equals(policyType)) { + if(SINGLE_HD.equals(policyType)) { append(scriptType.getDescriptor(), "descriptor-text"); replace(getLength(), getLength(), keystores.get(0).getScriptName(), List.of(keystores.get(0).isValid() ? "descriptor-text" : "descriptor-error", keystores.get(0).getScriptName())); append(scriptType.getCloseDescriptor(), "descriptor-text"); } - if(MULTI.equals(policyType)) { + if(MULTI_HD.equals(policyType)) { append(scriptType.getDescriptor(), "descriptor-text"); append(MULTISIG.getDescriptor(), "descriptor-text"); append(Integer.toString(threshold), "descriptor-text"); @@ -52,6 +51,12 @@ public class DescriptorArea extends CodeArea { append(MULTISIG.getCloseDescriptor(), "descriptor-text"); append(scriptType.getCloseDescriptor(), "descriptor-text"); } + + if(SINGLE_SP.equals(policyType)) { + append("sp(", "descriptor-text"); + replace(getLength(), getLength(), keystores.get(0).getScriptName(), List.of(keystores.get(0).isValid() ? "descriptor-text" : "descriptor-error", keystores.get(0).getScriptName())); + append(")", "descriptor-text"); + } } public Wallet getWallet() { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 53ad4bd0..7df69bed 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.OsType; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.silentpayments.SilentPayment; import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; @@ -408,7 +409,7 @@ public class EntryCell extends TreeTableCell implements Confirmati private static boolean canSignMessage(WalletNode walletNode) { Wallet wallet = walletNode.getWallet(); - return wallet.getKeystores().size() == 1 && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE); + return wallet.getPolicyType() == PolicyType.SINGLE_HD && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE); } private static boolean containsWalletOutputs(TransactionEntry transactionEntry) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java index 81ffd20c..f95c948b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java @@ -5,7 +5,7 @@ import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.hummingbird.UR; -import com.sparrowwallet.hummingbird.registry.CryptoOutput; +import com.sparrowwallet.hummingbird.registry.RegistryItem; import com.sparrowwallet.hummingbird.registry.RegistryType; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; @@ -32,7 +32,7 @@ import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.Optional; -import static com.sparrowwallet.sparrow.wallet.SettingsController.getCryptoOutput; +import static com.sparrowwallet.sparrow.wallet.SettingsController.getUROutputDescriptor; public class FileWalletExportPane extends TitledDescriptionPane { private final Wallet wallet; @@ -176,9 +176,9 @@ public class FileWalletExportPane extends TitledDescriptionPane { boolean addBbqrOption = exportWallet.getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr()); QREncoding encoding = exportWallet.getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr()) ? QREncoding.BBQR : QREncoding.UR; OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null); - CryptoOutput cryptoOutput = getCryptoOutput(exportWallet); + RegistryItem registryItem = getUROutputDescriptor(exportWallet); BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.UNICODE, outputDescriptor.toString(true).getBytes(StandardCharsets.UTF_8)) : null; - qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR(), bbqr, encoding); + qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), registryItem.toUR(), bbqr, encoding); } else if(exporter.getClass().equals(ColdcardMultisig.class)) { UR ur = UR.fromBytes(outputStream.toByteArray()); BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray()); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java index 912d3a82..87cbc341 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java @@ -14,6 +14,8 @@ import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBTInput; import com.sparrowwallet.drongo.psbt.PSBTProofException; +import com.sparrowwallet.drongo.silentpayments.*; +import com.sparrowwallet.drongo.wallet.Payment; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.WalletModel; import com.sparrowwallet.sparrow.AppServices; @@ -66,6 +68,7 @@ public class PrivateKeySweepDialog extends Dialog { private final ComboBox toWallet; private final FeeRangeSlider feeRange; private final CopyableLabel feeRate; + private SilentPaymentAddress silentPaymentAddress; public PrivateKeySweepDialog(Wallet wallet) { final DialogPane dialogPane = getDialogPane(); @@ -204,18 +207,31 @@ public class PrivateKeySweepDialog extends Dialog { }); toAddress.textProperty().addListener((observable, oldValue, newValue) -> { + try { + silentPaymentAddress = SilentPaymentAddress.from(newValue); + } catch(Exception e) { + silentPaymentAddress = null; + } createButton.setDisable(!isValidKey() || !isValidToAddress()); }); toWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> { if(selectedWallet != null) { - toAddress.setText(selectedWallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString()); + if(selectedWallet.getPolicyType() == PolicyType.SINGLE_SP) { + toAddress.setText(selectedWallet.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); + } else { + toAddress.setText(selectedWallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString()); + } } }); keyScriptType.setValue(ScriptType.P2PKH); if(wallet != null) { - toAddress.setText(wallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString()); + if(wallet.getPolicyType() == PolicyType.SINGLE_SP) { + toAddress.setText(wallet.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); + } else { + toAddress.setText(wallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString()); + } } AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(null)); @@ -272,10 +288,13 @@ public class PrivateKeySweepDialog extends Dialog { } private boolean isValidToAddress() { - try { - Address address = getToAddress(); + if(silentPaymentAddress != null) { return true; - } catch (InvalidAddressException e) { + } + try { + getToAddress(); + return true; + } catch(InvalidAddressException e) { return false; } } @@ -347,7 +366,8 @@ public class PrivateKeySweepDialog extends Dialog { DumpedPrivateKey privateKey = getPrivateKey(); ScriptType scriptType = keyScriptType.getValue(); Address fromAddress = scriptType.getAddress(PolicyType.SINGLE_HD, privateKey.getKey()); - Address destAddress = getToAddress(); + Payment payment = silentPaymentAddress != null ? new SilentPayment(silentPaymentAddress, null, 0, true) + : new Payment(getToAddress(), null, 0, true); Date since = null; if(Config.get().getServerType() == ServerType.BITCOIN_CORE) { @@ -363,7 +383,7 @@ public class PrivateKeySweepDialog extends Dialog { ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress, since); addressUtxosService.setOnSucceeded(successEvent -> { - createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress); + createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), payment); }); addressUtxosService.setOnFailed(failedEvent -> { Throwable rootCause = Throwables.getRootCause(failedEvent.getSource().getException()); @@ -383,7 +403,8 @@ public class PrivateKeySweepDialog extends Dialog { } } - private void createTransaction(ECKey privKey, ScriptType scriptType, List txOutputs, Address destAddress) { + private void createTransaction(ECKey privKey, ScriptType scriptType, List txOutputs, Payment payment) { + Address destAddress = payment instanceof SilentPayment silentPayment ? computeSilentPaymentAddress(privKey, scriptType, txOutputs, silentPayment) : payment.getAddress(); ECKey pubKey = ECKey.fromPublicOnly(privKey); Transaction noFeeTransaction = new Transaction(); @@ -468,6 +489,29 @@ public class PrivateKeySweepDialog extends Dialog { } } + private Address computeSilentPaymentAddress(ECKey privKey, ScriptType scriptType, List txOutputs, SilentPayment silentPayment) { + ECKey summedPrivateKey = scriptType.getOutputKey(PolicyType.SINGLE_HD, privKey); + if(scriptType == P2TR && summedPrivateKey.hasOddYCoord()) { + summedPrivateKey = summedPrivateKey.negatePrivate(); + } + + Set outpoints = new LinkedHashSet<>(); + for(TransactionOutput txOutput : txOutputs) { + outpoints.add(new HashIndex(txOutput.getHash(), txOutput.getIndex())); + } + + try { + SilentPaymentUtils.computeOutputAddresses(List.of(silentPayment), summedPrivateKey, outpoints); + if(!silentPayment.isAddressComputed()) { + throw new IllegalStateException("Failed to compute silent payment address"); + } + + return silentPayment.getAddress(); + } catch(InvalidSilentPaymentException e) { + throw new IllegalStateException("Failed to compute silent payment address", e); + } + } + public Glyph getGlyph(FontAwesome5.Glyph glyphEnum) { Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphEnum); glyph.setFontSize(12); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index 4f4e2904..9e384f84 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -51,6 +51,7 @@ import org.openpnp.capture.CaptureDevice; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.math.BigInteger; import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.charset.CharsetDecoder; @@ -582,6 +583,14 @@ public class QRScanDialog extends Dialog { } } + private ECKey getKey(CryptoHDKey cryptoHDKey) { + if(cryptoHDKey.isPrivateKey()) { + return ECKey.fromPrivate(new BigInteger(1, cryptoHDKey.getKey())); + } else { + return ECKey.fromPublicOnly(cryptoHDKey.getKey()); + } + } + private OutputDescriptor getOutputDescriptor(CryptoOutput cryptoOutput) { ScriptType scriptType = getScriptType(cryptoOutput.getScriptExpressions()); @@ -679,11 +688,16 @@ public class QRScanDialog extends Dialog { for(int i = 0; i < keys.size(); i++) { RegistryItem key = keys.get(i); if(key instanceof URHDKey urhdKey) { - ExtendedKey extendedKey = getExtendedKey(urhdKey); KeyDerivation keyDerivation = getKeyDerivation(urhdKey.getOrigin()); - source = source.replaceAll("@" + i, OutputDescriptor.writeKey(extendedKey, keyDerivation, null, true, true)); - if(urhdKey.getName() != null) { - mapExtendedPublicKeyLabels.put(extendedKey, urhdKey.getName()); + if(urhdKey.getChainCode() == null) { + ECKey ecKey = getKey(urhdKey); + source = source.replaceAll("@" + i, OutputDescriptor.writeKey(ecKey, keyDerivation, true)); + } else { + ExtendedKey extendedKey = getExtendedKey(urhdKey); + source = source.replaceAll("@" + i, OutputDescriptor.writeKey(extendedKey, keyDerivation, null, true, true)); + if(urhdKey.getName() != null) { + mapExtendedPublicKeyLabels.put(extendedKey, urhdKey.getName()); + } } } else { throw new IllegalArgumentException("Only extended HD keys are supported in output descriptors"); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java index 82d12a90..14be0ce8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java @@ -44,13 +44,13 @@ public class WalletExportDialog extends Dialog { AnchorPane.setRightAnchor(scrollPane, 0.0); List exporters; - if(wallet.getPolicyType() == PolicyType.SINGLE) { + if(wallet.getPolicyType() == PolicyType.SINGLE_HD) { exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm)); - } else if(wallet.getPolicyType() == PolicyType.MULTI) { + } else if(wallet.getPolicyType() == PolicyType.MULTI_HD) { exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm)); - } else if(wallet.getPolicyType() == PolicyType.SINGLE_SILENT_PAYMENTS) { - exporters = List.of(new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm)); + } else if(wallet.getPolicyType() == PolicyType.SINGLE_SP) { + exporters = List.of(new Descriptor(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm)); } else { throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SettingsChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SettingsChangedEvent.java index ddfb0997..199a15f8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/SettingsChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/SettingsChangedEvent.java @@ -20,6 +20,6 @@ public class SettingsChangedEvent { } public enum Type { - POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, GAP_LIMIT, BIRTH_DATE, WATCH_LAST; + POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, KEYSTORE_SP_SCAN, GAP_LIMIT, BIRTH_DATE, WATCH_LAST; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java b/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java index ea8acb1b..7ba96de7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java @@ -3,10 +3,10 @@ package com.sparrowwallet.sparrow.io; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.OutputDescriptor; +import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.WalletModel; -import com.sparrowwallet.sparrow.wallet.KeystoreController; import java.io.*; import java.nio.charset.StandardCharsets; @@ -29,26 +29,32 @@ public class Descriptor implements WalletImport, WalletExport { public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException { try { BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream)); - bufferedWriter.write("# Receive and change descriptor (BIP389):"); - bufferedWriter.newLine(); + if(wallet.getPolicyType() == PolicyType.SINGLE_SP) { + OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet); + bufferedWriter.write(outputDescriptor.toString(true)); + bufferedWriter.newLine(); + } else { + bufferedWriter.write("# Receive and change descriptor:"); + bufferedWriter.newLine(); - OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null); - bufferedWriter.write(outputDescriptor.toString(true)); - bufferedWriter.newLine(); - bufferedWriter.newLine(); - bufferedWriter.newLine(); + OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null); + bufferedWriter.write(outputDescriptor.toString(true)); + bufferedWriter.newLine(); + bufferedWriter.newLine(); + bufferedWriter.newLine(); - bufferedWriter.write("# Receive descriptor (Bitcoin Core):"); - bufferedWriter.newLine(); - OutputDescriptor receiveDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE, null); - bufferedWriter.write(receiveDescriptor.toString(true)); - bufferedWriter.newLine(); - bufferedWriter.newLine(); - bufferedWriter.write("# Change descriptor (Bitcoin Core):"); - bufferedWriter.newLine(); - OutputDescriptor changeDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.CHANGE, null); - bufferedWriter.write(changeDescriptor.toString(true)); - bufferedWriter.newLine(); + bufferedWriter.write("# Receive descriptor:"); + bufferedWriter.newLine(); + OutputDescriptor receiveDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE, null); + bufferedWriter.write(receiveDescriptor.toString(true)); + bufferedWriter.newLine(); + bufferedWriter.newLine(); + bufferedWriter.write("# Change descriptor:"); + bufferedWriter.newLine(); + OutputDescriptor changeDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.CHANGE, null); + bufferedWriter.write(changeDescriptor.toString(true)); + bufferedWriter.newLine(); + } bufferedWriter.flush(); } catch(Exception e) { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java index 71c2f1e2..f461754f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java @@ -188,7 +188,7 @@ public class Storage { keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase()); keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); - keystore.setSilentPaymentScanAddress(wallet.getPolicyType() == PolicyType.SINGLE_SP ? derivedKeystore.getSilentPaymentScanAddress() : null); + keystore.setSilentPaymentScanAddress(derivedKeystore.getSilentPaymentScanAddress()); copyKeystore.getSeed().clear(); } else if(keystore.hasMasterPrivateExtendedKey()) { Keystore copyKeystore = copy.getKeystores().get(i); @@ -196,7 +196,7 @@ public class Storage { keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); - keystore.setSilentPaymentScanAddress(wallet.getPolicyType() == PolicyType.SINGLE_SP ? derivedKeystore.getSilentPaymentScanAddress() : null); + keystore.setSilentPaymentScanAddress(derivedKeystore.getSilentPaymentScanAddress()); copyKeystore.getMasterPrivateKey().clear(); } } 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 e417322f..25fd20ab 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java @@ -71,7 +71,7 @@ public interface KeystoreDao { long id = insert(truncate(keystore.getLabel()), keystore.getSource().ordinal(), keystore.getWalletModel().ordinal(), keystore.hasMasterPrivateKey() || wallet.isBip47() ? null : keystore.getKeyDerivation().getMasterFingerprint(), keystore.getKeyDerivation().getDerivationPath(), - keystore.hasMasterPrivateKey() || wallet.isBip47() ? null : keystore.getExtendedPublicKey().toString(), + keystore.hasMasterPrivateKey() || wallet.isBip47() || keystore.getExtendedPublicKey() == null ? null : keystore.getExtendedPublicKey().toString(), keystore.getExternalPaymentCode() == null ? null : keystore.getExternalPaymentCode().toString(), keystore.getSilentPaymentScanAddress() == null ? null : keystore.getSilentPaymentScanAddress().toBytes(), keystore.getDeviceRegistration(), diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java index 0ee0281d..36005bf1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java @@ -112,7 +112,7 @@ public interface WalletDao { wallet.getKeystores().addAll(createKeystoreDao().getForWalletId(wallet.getId())); List walletNodes = createWalletNodeDao().getForWalletId(wallet.getScriptType().ordinal(), wallet.getId()); - wallet.getPurposeNodes().addAll(walletNodes.stream().filter(walletNode -> walletNode.getDerivation().size() == 1).collect(Collectors.toList())); + wallet.getPurposeNodes().addAll(walletNodes.stream().filter(WalletNode::isPurposeNode).collect(Collectors.toList())); wallet.getPurposeNodes().forEach(walletNode -> walletNode.setWallet(wallet)); Map blockTransactions = createBlockTransactionDao().getForWalletId(wallet.getId()); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeMapper.java b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeMapper.java index a23b86bf..b84a3f07 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeMapper.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeMapper.java @@ -16,7 +16,7 @@ public class WalletNodeMapper implements RowMapper { walletNode.setLabel(rs.getString("walletNode.label")); byte[] addressData = rs.getBytes("walletNode.addressData"); if(addressData != null) { - ScriptType scriptType = ScriptType.values()[rs.getInt(6)]; + ScriptType scriptType = ScriptType.values()[rs.getInt(7)]; walletNode.setAddress(scriptType.getAddress(addressData)); } walletNode.setSilentPaymentTweak(rs.getBytes("walletNode.silentPaymentTweak")); diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java index fb7f1caa..ca879b96 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java @@ -75,7 +75,7 @@ public class SettingsDialog extends WalletDialog { Panel leftButtonPanel = new Panel(); leftButtonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); leftButtonPanel.addComponent(new Button("Add Account", this::showAddAccount)); - if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE || getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SILENT_PAYMENTS) { + if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_HD || getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) { leftButtonPanel.addComponent(new Button("Show Seed", this::showSeed)); } else { leftButtonPanel.addComponent(new EmptySpace(TerminalSize.ZERO)); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java index 3b547a66..5ac22c14 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java @@ -4,6 +4,7 @@ import com.google.common.eventbus.Subscribe; import com.sparrowwallet.drongo.*; import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; @@ -86,6 +87,12 @@ public class KeystoreController extends WalletFormController implements Initiali @FXML private TextArea xpub; + @FXML + private Field spScanField; + + @FXML + private TextArea spScan; + @FXML private TextField derivation; @@ -152,12 +159,20 @@ public class KeystoreController extends WalletFormController implements Initiali derivation.setPromptText(getWalletForm().getWallet().getScriptType().getDefaultDerivationPath()); - if(keystore.getExtendedPublicKey() != null) { + xpubField.managedProperty().bind(xpubField.visibleProperty()); + spScanField.managedProperty().bind(spScanField.visibleProperty()); + spScanField.visibleProperty().bind(xpubField.visibleProperty().not()); + + if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP && keystore.getSilentPaymentScanAddress() != null) { + spScan.setText(keystore.getSilentPaymentScanAddress().toKeyString()); + setSpScanContext(keystore.getSilentPaymentScanAddress()); + } else if(keystore.getExtendedPublicKey() != null) { xpub.setText(keystore.getExtendedPublicKey().toString()); setXpubContext(keystore.getExtendedPublicKey()); } else { switchXpubHeader.setDisable(true); xpubField.setText(Network.get().getXpubHeader().getDisplayName() + ":"); + spScanField.setText(Network.get().getSilentPaymentsScanKeyHrp() + ":"); } if(keystore.getKeyDerivation() != null) { @@ -210,6 +225,17 @@ public class KeystoreController extends WalletFormController implements Initiali } scanXpubQR.setVisible(!valid); }); + spScan.textProperty().addListener((observable, oldValue, newValue) -> { + boolean valid = SilentPaymentScanAddress.isValid(newValue); + if(valid) { + SilentPaymentScanAddress silentPaymentScanAddress = SilentPaymentScanAddress.fromKeyString(newValue); + setSpScanContext(silentPaymentScanAddress); + if(!silentPaymentScanAddress.equals(keystore.getSilentPaymentScanAddress())) { + keystore.setSilentPaymentScanAddress(silentPaymentScanAddress); + EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.KEYSTORE_SP_SCAN)); + } + } + }); if(keystore.getSource() != KeystoreSource.SW_WATCH && (!walletForm.getWallet().isMasterWallet() || !walletForm.getWallet().getChildWallets().isEmpty())) { setInputFieldsDisabled(true); @@ -252,6 +278,21 @@ public class KeystoreController extends WalletFormController implements Initiali scanXpubQR.setVisible(false); } + private void setSpScanContext(SilentPaymentScanAddress silentPaymentScanAddress) { + ContextMenu contextMenu = new ContextMenu(); + MenuItem copySpScan = new MenuItem("Copy " + Network.get().getSilentPaymentsScanKeyHrp()); + copySpScan.setOnAction(AE -> { + contextMenu.hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(silentPaymentScanAddress.toKeyString()); + Clipboard.getSystemClipboard().setContent(content); + }); + contextMenu.getItems().add(copySpScan); + + spScanField.setText(Network.get().getSilentPaymentsScanKeyHrp() + ":"); + spScan.setContextMenu(contextMenu); + } + public void selectSource(ActionEvent event) { keystoreSourceToggleGroup.selectToggle(null); ToggleButton sourceButton = (ToggleButton)event.getSource(); @@ -260,7 +301,8 @@ public class KeystoreController extends WalletFormController implements Initiali launchImportDialog(keystoreSource); } else { fingerprint.setText(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT); - derivation.setText(getWalletForm().getWallet().getScriptType().getDefaultDerivationPath()); + derivation.setText(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP ? KeyDerivation.writePath(KeyDerivation.getBip352Derivation(0)) + : getWalletForm().getWallet().getScriptType().getDefaultDerivationPath()); selectSourcePane.setVisible(false); } } @@ -283,12 +325,19 @@ public class KeystoreController extends WalletFormController implements Initiali )); validationSupport.registerValidator(xpub, Validator.combine( - Validator.createEmptyValidator(Network.get().getXpubHeader().getDisplayName() + " is required"), - (Control c, String newValue) -> ValidationResult.fromErrorIf( c, Network.get().getXpubHeader().getDisplayName() + " is invalid", !ExtendedKey.isValid(newValue)), - (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Extended key is not unique", ExtendedKey.isValid(newValue) && + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, Network.get().getXpubHeader().getDisplayName() + " is required", getWalletForm().getWallet().getPolicyType() != PolicyType.SINGLE_SP && newValue.trim().isEmpty()), + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, Network.get().getXpubHeader().getDisplayName() + " is invalid", getWalletForm().getWallet().getPolicyType() != PolicyType.SINGLE_SP && !ExtendedKey.isValid(newValue)), + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Extended key is not unique", ExtendedKey.isValid(newValue) && getWalletForm().getWallet().getPolicyType() != PolicyType.SINGLE_SP && walletForm.getWallet().getKeystores().stream().filter(k -> k != keystore && k.getExtendedPublicKey() != null).map(Keystore::getExtendedPublicKey).collect(Collectors.toList()).contains(ExtendedKey.fromDescriptor(newValue))) )); + validationSupport.registerValidator(spScan, Validator.combine( + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, Network.get().getSilentPaymentsScanKeyHrp() + " is required", getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP && newValue.trim().isEmpty()), + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, Network.get().getSilentPaymentsScanKeyHrp() + " is invalid", getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP && !SilentPaymentScanAddress.isValid(newValue)), + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, Network.get().getSilentPaymentsScanKeyHrp() + " is not unique", SilentPaymentScanAddress.isValid(newValue) && getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP && + walletForm.getWallet().getKeystores().stream().filter(k -> k != keystore && k.getSilentPaymentScanAddress() != null).map(Keystore::getSilentPaymentScanAddress).collect(Collectors.toList()).contains(SilentPaymentScanAddress.fromKeyString(newValue))) + )); + validationSupport.registerValidator(derivation, Validator.combine( Validator.createEmptyValidator("Derivation is required"), (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Derivation is invalid", !KeyDerivation.isValid(newValue)), @@ -305,7 +354,7 @@ public class KeystoreController extends WalletFormController implements Initiali private void updateType(boolean showExport) { type.setText(getTypeLabel(keystore)); type.setGraphic(getTypeIcon(keystore)); - exportButton.setVisible(showExport && getWalletForm().getWallet().getPolicyType() == PolicyType.MULTI); + exportButton.setVisible(showExport && getWalletForm().getWallet().getPolicyType() == PolicyType.MULTI_HD); viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasSeed()); viewKeyButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasMasterPrivateExtendedKey()); cardServiceButtons.setVisible(keystore.getWalletModel().isCard()); @@ -319,6 +368,9 @@ public class KeystoreController extends WalletFormController implements Initiali setEditable(derivation, editable); setEditable(xpub, editable); scanXpubQR.setVisible(editable); + setEditable(spScan, editable); + + xpubField.setVisible(getWalletForm().getWallet().getPolicyType() != PolicyType.SINGLE_SP); } private void setEditable(TextInputControl textInputControl, boolean editable) { @@ -374,6 +426,9 @@ public class KeystoreController extends WalletFormController implements Initiali private void launchImportDialog(KeystoreSource initialSource) { boolean restrictImport = keystore.getSource() != KeystoreSource.SW_WATCH && keystoreSourceToggleGroup.getToggles().stream().anyMatch(toggle -> ((ToggleButton)toggle).isDisabled()); KeyDerivation currentDerivation = keystore.getKeyDerivation(); + if((currentDerivation == null || currentDerivation.getDerivation().isEmpty()) && getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) { + currentDerivation = new KeyDerivation(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT, KeyDerivation.getBip352Derivation(0)); + } WalletModel currentModel = keystore.getWalletModel(); String currentLabel = keystore.getLabel(); KeystoreImportDialog dlg = new KeystoreImportDialog(getWalletForm().getWallet(), initialSource, currentDerivation, currentModel, currentLabel, restrictImport); @@ -405,17 +460,22 @@ public class KeystoreController extends WalletFormController implements Initiali keystore.setMasterPrivateExtendedKey(importedKeystore.getMasterPrivateExtendedKey()); keystore.setSeed(importedKeystore.getSeed()); keystore.setBip47ExtendedPrivateKey(importedKeystore.getBip47ExtendedPrivateKey()); + keystore.setSilentPaymentScanAddress(importedKeystore.getSilentPaymentScanAddress()); updateType(keystore.isValid()); label.setText(keystore.getLabel()); fingerprint.setText(keystore.getKeyDerivation().getMasterFingerprint()); derivation.setText(keystore.getKeyDerivation().getDerivationPath()); - if(keystore.getExtendedPublicKey() != null) { + if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP && keystore.getSilentPaymentScanAddress() != null) { + spScan.setText(keystore.getSilentPaymentScanAddress().toKeyString()); + setSpScanContext(keystore.getSilentPaymentScanAddress()); + } else if(keystore.getExtendedPublicKey() != null) { xpub.setText(keystore.getExtendedPublicKey().toString()); setXpubContext(keystore.getExtendedPublicKey()); } else { xpub.setText(""); + spScan.setText(""); } } } @@ -651,6 +711,7 @@ public class KeystoreController extends WalletFormController implements Initiali setEditable(fingerprint, !disabled); setEditable(derivation, !disabled); setEditable(xpub, !disabled); + setEditable(spScan, !disabled); importButton.setDisable(disabled); } @@ -674,9 +735,12 @@ public class KeystoreController extends WalletFormController implements Initiali derivation.setText(derivationPath + " "); derivation.setText(derivationPath); } - if(keystore.getExtendedPublicKey() != null) { + if(keystore.getExtendedPublicKey() != null && walletForm.getWallet().getPolicyType() != PolicyType.SINGLE_SP) { setXpubContext(keystore.getExtendedPublicKey()); } + if(keystore.getSilentPaymentScanAddress() != null && walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP) { + setSpScanContext(keystore.getSilentPaymentScanAddress()); + } } else if(event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_LABEL)) { if(!keystore.getLabel().equals(label.getText())) { label.setText(keystore.getLabel()); @@ -686,7 +750,7 @@ public class KeystoreController extends WalletFormController implements Initiali if(event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_LABEL) || event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_FINGERPRINT) || event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_DERIVATION) || event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_XPUB)) { if(keystore.getSource() == KeystoreSource.SW_WATCH) { - exportButton.setVisible(keystore.isValid() && getWalletForm().getWallet().getPolicyType() == PolicyType.MULTI); + exportButton.setVisible(keystore.isValid() && getWalletForm().getWallet().getPolicyType() == PolicyType.MULTI_HD); } } } @@ -696,7 +760,8 @@ public class KeystoreController extends WalletFormController implements Initiali public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) { if(event.getWalletId().equals(walletForm.getWalletId())) { for(Keystore changedKeystore : event.getChangedKeystores()) { - if(xpub.getText().trim().equals(changedKeystore.getExtendedPublicKey().toString()) && !label.getText().equals(changedKeystore.getLabel())) { + if(xpub.getText().trim().equals(changedKeystore.getExtendedPublicKey().toString()) && !label.getText().equals(changedKeystore.getLabel()) + || spScan.getText().trim().equals(changedKeystore.getSilentPaymentScanAddress().toKeyString()) && !label.getText().equals(changedKeystore.getLabel())) { label.textProperty().removeListener(labelChangeListener); label.setText(changedKeystore.getLabel()); keystore.setLabel(changedKeystore.getLabel()); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java index 11cd8582..a8f98c9f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -11,6 +11,7 @@ import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException; import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.dns.DnsPaymentCache; +import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.protocol.TransactionOutput; @@ -314,14 +315,18 @@ public class PaymentController extends WalletFormController implements Initializ label.requestFocus(); } } else if(newValue != null) { - List
existingAddresses = getOtherAddresses(); - WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE); - Address freshAddress = freshNode.getAddress(); - while(existingAddresses.contains(freshAddress) || (freshNode.getLabel() != null && !freshNode.getLabel().isEmpty())) { - freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE, freshNode); - freshAddress = freshNode.getAddress(); + if(newValue.getPolicyType() == PolicyType.SINGLE_SP) { + address.setText(newValue.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); + } else { + List
existingAddresses = getOtherAddresses(); + WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE); + Address freshAddress = freshNode.getAddress(); + while(existingAddresses.contains(freshAddress) || (freshNode.getLabel() != null && !freshNode.getLabel().isEmpty())) { + freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE, freshNode); + freshAddress = freshNode.getAddress(); + } + address.setText(freshAddress.toString()); } - address.setText(freshAddress.toString()); label.requestFocus(); } }); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java index 158515ac..d3f4ba3e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java @@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.crypto.*; import com.sparrowwallet.drongo.policy.Policy; import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.registry.*; @@ -118,6 +119,8 @@ public class SettingsController extends WalletFormController implements Initiali keystoreTabs = new TabPane(); keystoreTabsPane.getChildren().add(keystoreTabs); + policyType.setButtonCell(new PolicyTypeButtonCell()); + policyType.setCellFactory(_ -> new PolicyTypeListCell()); policyType.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, policyType) -> { walletForm.getWallet().setPolicyType(policyType); @@ -131,8 +134,8 @@ public class SettingsController extends WalletFormController implements Initiali } initialising = false; - multisigFieldset.setVisible(policyType.equals(PolicyType.MULTI)); - if(policyType.equals(PolicyType.MULTI)) { + multisigFieldset.setVisible(policyType.equals(PolicyType.MULTI_HD)); + if(policyType.equals(PolicyType.MULTI_HD)) { totalKeystores.bind(multisigControl.highValueProperty()); } else { totalKeystores.set(1); @@ -167,7 +170,7 @@ public class SettingsController extends WalletFormController implements Initiali return; } else if(optType.get() == ButtonType.YES) { clearKeystoreTabs(); - if(walletForm.getWallet().getPolicyType() == PolicyType.MULTI) { + if(walletForm.getWallet().getPolicyType() == PolicyType.MULTI_HD) { totalKeystores.bind(multisigControl.highValueProperty()); } else { totalKeystores.set(1); @@ -225,7 +228,7 @@ public class SettingsController extends WalletFormController implements Initiali keystoreTabs.getTabs().remove(keystoreTabs.getTabs().size() - 1); } - if(walletForm.getWallet().getPolicyType().equals(PolicyType.MULTI)) { + if(walletForm.getWallet().getPolicyType().equals(PolicyType.MULTI_HD)) { EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.MULTISIG_TOTAL)); } }); @@ -261,10 +264,10 @@ public class SettingsController extends WalletFormController implements Initiali saveWallet(false, false); Wallet wallet = walletForm.getWallet(); - if(wallet.getPolicyType() == PolicyType.MULTI && wallet.getDefaultPolicy().getNumSignaturesRequired() < wallet.getKeystores().size() && addressChange) { + if(wallet.getPolicyType() == PolicyType.MULTI_HD && wallet.getDefaultPolicy().getNumSignaturesRequired() < wallet.getKeystores().size() && addressChange) { String outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null).toString(true); - CryptoOutput cryptoOutput = getCryptoOutput(wallet); - MultisigBackupDialog dialog = new MultisigBackupDialog(wallet, outputDescriptor, cryptoOutput.toUR()); + RegistryItem registryItem = getUROutputDescriptor(wallet); + MultisigBackupDialog dialog = new MultisigBackupDialog(wallet, outputDescriptor, registryItem.toUR()); dialog.initOwner(apply.getScene().getWindow()); dialog.showAndWait(); } @@ -281,7 +284,7 @@ public class SettingsController extends WalletFormController implements Initiali private void setFieldsFromWallet(Wallet wallet) { if(wallet.getPolicyType() == null) { - wallet.setPolicyType(PolicyType.SINGLE); + wallet.setPolicyType(PolicyType.SINGLE_HD); wallet.setScriptType(ScriptType.P2WPKH); Keystore keystore = new Keystore("Keystore 1"); keystore.setSource(KeystoreSource.SW_WATCH); @@ -290,9 +293,9 @@ public class SettingsController extends WalletFormController implements Initiali wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), 1)); } - if(wallet.getPolicyType().equals(PolicyType.SINGLE)) { + if(wallet.getPolicyType().equals(PolicyType.SINGLE_HD) || wallet.getPolicyType().equals(PolicyType.SINGLE_SP)) { totalKeystores.setValue(1); - } else if(wallet.getPolicyType().equals(PolicyType.MULTI)) { + } else if(wallet.getPolicyType().equals(PolicyType.MULTI_HD)) { multisigControl.setMax(Math.max(multisigControl.getMax(), wallet.getKeystores().size())); multisigControl.highValueProperty().set(wallet.getKeystores().size()); multisigControl.lowValueProperty().set(wallet.getDefaultPolicy().getNumSignaturesRequired()); @@ -376,8 +379,8 @@ public class SettingsController extends WalletFormController implements Initiali } OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet(), KeyPurpose.DEFAULT_PURPOSES, null); - CryptoOutput cryptoOutput = getCryptoOutput(walletForm.getWallet()); - if(cryptoOutput == null) { + RegistryItem registryItem = getUROutputDescriptor(walletForm.getWallet()); + if(registryItem == null) { AppServices.showErrorDialog("Unsupported Wallet Policy", "Cannot show a descriptor for this wallet."); return; } @@ -385,32 +388,45 @@ public class SettingsController extends WalletFormController implements Initiali boolean addBbqrOption = walletForm.getWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr()); QREncoding encoding = walletForm.getWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr()) ? QREncoding.BBQR : QREncoding.UR; - UR cryptoOutputUR = cryptoOutput.toUR(); + UR cryptoOutputUR = registryItem.toUR(); BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.UNICODE, outputDescriptor.toString(true).getBytes(StandardCharsets.UTF_8)) : null; QRDisplayDialog qrDisplayDialog = new DescriptorQRDisplayDialog(walletForm.getWallet().getFullDisplayName(), outputDescriptor.toString(true), cryptoOutputUR, bbqr, encoding); qrDisplayDialog.initOwner(showDescriptorQR.getScene().getWindow()); qrDisplayDialog.showAndWait(); } - public static CryptoOutput getCryptoOutput(Wallet wallet) { + public static RegistryItem getUROutputDescriptor(Wallet wallet) { List scriptExpressions = getScriptExpressions(wallet.getScriptType()); - CryptoOutput cryptoOutput = null; - if(wallet.getPolicyType() == PolicyType.SINGLE) { - cryptoOutput = new CryptoOutput(scriptExpressions, getCryptoHDKey(wallet.getKeystores().get(0))); - } else if(wallet.getPolicyType() == PolicyType.MULTI) { + RegistryItem registryItem = null; + if(wallet.getPolicyType() == PolicyType.SINGLE_HD) { + Keystore keystore = wallet.getKeystores().getFirst(); + KeyDerivation keyDerivation = keystore.getKeyDerivation(); + registryItem = new CryptoOutput(scriptExpressions, getCryptoHDKey(keyDerivation.getMasterFingerprint(), keyDerivation.getDerivation(), keystore.getExtendedPublicKey(), keystore.getLabel())); + } else if(wallet.getPolicyType() == PolicyType.MULTI_HD) { WalletNode firstReceive = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); Utils.LexicographicByteArrayComparator lexicographicByteArrayComparator = new Utils.LexicographicByteArrayComparator(); List cryptoHDKeys = wallet.getKeystores().stream().sorted((keystore1, keystore2) -> { return lexicographicByteArrayComparator.compare(keystore1.getPubKey(firstReceive).getPubKey(), keystore2.getPubKey(firstReceive).getPubKey()); - }).map(SettingsController::getCryptoHDKey).collect(Collectors.toList()); + }).map(keystore -> { + KeyDerivation keyDerivation = keystore.getKeyDerivation(); + return getCryptoHDKey(keyDerivation.getMasterFingerprint(), keyDerivation.getDerivation(), keystore.getExtendedPublicKey(), keystore.getLabel()); + }).collect(Collectors.toList()); MultiKey multiKey = new MultiKey(wallet.getDefaultPolicy().getNumSignaturesRequired(), null, cryptoHDKeys); List multiScriptExpressions = new ArrayList<>(scriptExpressions); multiScriptExpressions.add(ScriptExpression.SORTED_MULTISIG); - cryptoOutput = new CryptoOutput(multiScriptExpressions, multiKey); + registryItem = new CryptoOutput(multiScriptExpressions, multiKey); + } else if(wallet.getPolicyType() == PolicyType.SINGLE_SP) { + Keystore keystore = wallet.getKeystores().getFirst(); + KeyDerivation keyDerivation = keystore.getKeyDerivation(); + SilentPaymentScanAddress spScanAddress = keystore.getSilentPaymentScanAddress(); + URHDKey scanKey = getURHDKey(keyDerivation.getMasterFingerprint(), KeyDerivation.getBip352ScanDerivation(keyDerivation.getDerivation()), spScanAddress.getScanKey(), keystore.getLabel()); + URHDKey spendKey = getURHDKey(keyDerivation.getMasterFingerprint(), KeyDerivation.getBip352SpendDerivation(keyDerivation.getDerivation()), spScanAddress.getSpendKey(), keystore.getLabel()); + String annotations = wallet.getBirthHeight() != null ? "?" + OutputDescriptor.ANNOTATION_BLOCK_HEIGHT + "=" + wallet.getBirthHeight() : ""; + registryItem = new UROutputDescriptor("sp(@0,@1)" + annotations, List.of(scanKey, spendKey), wallet.getFullDisplayName(), null); } - return cryptoOutput; + return registryItem; } private static List getScriptExpressions(ScriptType scriptType) { @@ -435,12 +451,18 @@ public class SettingsController extends WalletFormController implements Initiali throw new IllegalArgumentException("Unknown script type of " + scriptType); } - private static CryptoHDKey getCryptoHDKey(Keystore keystore) { - ExtendedKey extendedKey = keystore.getExtendedPublicKey(); + private static CryptoHDKey getCryptoHDKey(String masterFingerprint, List derivation, ExtendedKey extendedKey, String label) { CryptoCoinInfo cryptoCoinInfo = new CryptoCoinInfo(CryptoCoinInfo.Type.BITCOIN.ordinal(), Network.get() == Network.MAINNET ? CryptoCoinInfo.Network.MAINNET.ordinal() : CryptoCoinInfo.Network.TESTNET.ordinal()); - List pathComponents = keystore.getKeyDerivation().getDerivation().stream().map(cNum -> new IndexPathComponent(cNum.num(), cNum.isHardened())).collect(Collectors.toList()); - CryptoKeypath cryptoKeypath = new CryptoKeypath(pathComponents, Utils.hexToBytes(keystore.getKeyDerivation().getMasterFingerprint()), pathComponents.size()); - return new CryptoHDKey(false, extendedKey.getKey().getPubKey(), extendedKey.getKey().getChainCode(), cryptoCoinInfo, cryptoKeypath, null, extendedKey.getParentFingerprint(), keystore.getLabel(), null); + List pathComponents = derivation.stream().map(cNum -> new IndexPathComponent(cNum.num(), cNum.isHardened())).collect(Collectors.toList()); + CryptoKeypath cryptoKeypath = new CryptoKeypath(pathComponents, Utils.hexToBytes(masterFingerprint), pathComponents.size()); + return new CryptoHDKey(false, extendedKey.getKey().getPubKey(), extendedKey.getKey().getChainCode(), cryptoCoinInfo, cryptoKeypath, null, extendedKey.getParentFingerprint(), label, null); + } + + private static URHDKey getURHDKey(String masterFingerprint, List derivation, ECKey key, String label) { + URCoinInfo cryptoCoinInfo = new URCoinInfo(URCoinInfo.Type.BITCOIN.ordinal(), Network.get() == Network.MAINNET ? URCoinInfo.Network.MAINNET.ordinal() : URCoinInfo.Network.TESTNET.ordinal()); + List pathComponents = derivation.stream().map(cNum -> new IndexPathComponent(cNum.num(), cNum.isHardened())).collect(Collectors.toList()); + URKeypath cryptoKeypath = new URKeypath(pathComponents, Utils.hexToBytes(masterFingerprint), pathComponents.size()); + return new URHDKey(key.hasPrivKey(), key.hasPrivKey() ? key.getPrivKeyBytes() : key.getPubKey(), null, cryptoCoinInfo, cryptoKeypath, null, null, label, null); } public void editDescriptor(ActionEvent event) { @@ -451,7 +473,7 @@ public class SettingsController extends WalletFormController implements Initiali dialog.initOwner(editDescriptor.getScene().getWindow()); dialog.setTitle("Edit wallet output descriptor"); dialog.getDialogPane().setHeaderText("The wallet configuration is specified in the output descriptor.\nChanges to the output descriptor will modify the wallet configuration." + - (walletForm.getWallet().getPolicyType() == PolicyType.MULTI ? "\nKey expressions are shown in canonical order." : "")); + (walletForm.getWallet().getPolicyType() == PolicyType.MULTI_HD ? "\nKey expressions are shown in canonical order." : "")); Optional text = dialog.showAndWait(); if(text.isPresent() && !text.get().isEmpty() && !text.get().equals(outputDescriptorString)) { if(text.get().contains("(multi(")) { @@ -514,6 +536,7 @@ public class SettingsController extends WalletFormController implements Initiali keystore.setWalletModel(existing.getWalletModel()); if(existing.getKeyDerivation().getDerivation().equals(keystore.getKeyDerivation().getDerivation())) { keystore.setExtendedPublicKey(existing.getExtendedPublicKey()); + keystore.setSilentPaymentScanAddress(existing.getSilentPaymentScanAddress()); } else { rederive = true; } @@ -597,7 +620,7 @@ public class SettingsController extends WalletFormController implements Initiali dialog.initOwner(showDescriptor.getScene().getWindow()); dialog.setTitle("Show wallet output descriptor"); dialog.getDialogPane().setHeaderText("The wallet configuration is specified in the output descriptor.\nThis wallet is no longer editable - create a new wallet to change the descriptor." + - (walletForm.getWallet().getPolicyType() == PolicyType.MULTI ? "\nKey expressions are shown in canonical order." : "")); + (walletForm.getWallet().getPolicyType() == PolicyType.MULTI_HD ? "\nKey expressions are shown in canonical order." : "")); dialog.showAndWait(); } @@ -724,7 +747,7 @@ public class SettingsController extends WalletFormController implements Initiali } } } else { - if(discoverAccounts && masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)) { + if(discoverAccounts && masterWallet.getPolicyType() == PolicyType.SINGLE_HD && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)) { String fingerprint = masterWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint(); DeviceKeystoreDiscoverDialog deviceKeystoreDiscoverDialog = new DeviceKeystoreDiscoverDialog(List.of(fingerprint), masterWallet, standardAccounts); deviceKeystoreDiscoverDialog.initOwner(addAccount.getScene().getWindow()); @@ -827,9 +850,9 @@ public class SettingsController extends WalletFormController implements Initiali public void update(SettingsChangedEvent event) { Wallet wallet = event.getWallet(); if(walletForm.getWallet().equals(wallet)) { - if(wallet.getPolicyType() == PolicyType.SINGLE) { + if(wallet.getPolicyType() == PolicyType.SINGLE_HD || wallet.getPolicyType() == PolicyType.SINGLE_SP) { wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), 1)); - } else if(wallet.getPolicyType() == PolicyType.MULTI) { + } else if(wallet.getPolicyType() == PolicyType.MULTI_HD) { wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), (int)multisigControl.getLowValue())); } @@ -1040,4 +1063,34 @@ public class SettingsController extends WalletFormController implements Initiali apply.setDisable(false); } } + + private static class PolicyTypeButtonCell extends ListCell { + @Override + protected void updateItem(PolicyType policyType, boolean empty) { + super.updateItem(policyType, empty); + if(policyType == null || empty) { + setText(""); + setGraphic(null); + } else { + setText(policyType.getName()); + setGraphic(null); + setGraphicTextGap(8.0d); + } + } + } + + private static class PolicyTypeListCell extends ListCell { + @Override + protected void updateItem(PolicyType policyType, boolean empty) { + super.updateItem(policyType, empty); + if(policyType == null || empty) { + setText(""); + setGraphic(null); + } else { + setText(policyType.getDescription()); + setGraphic(null); + setGraphicTextGap(8.0d); + } + } + } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.css index 501db4aa..9a6e90a8 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.css @@ -13,7 +13,7 @@ -fx-pref-width: 140px; } -#fingerprint, #derivation, #xpub { +#fingerprint, #derivation, #xpub, #spScan { -fx-font-size: 13px; -fx-font-family: 'Fragment Mono Regular'; } diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml index a690d1ca..58bfd5be 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml @@ -109,6 +109,9 @@ + +