support sp wallet import via all keystore importers
What changed, and why it matters
This commit adds the ability to import a new kind of Bitcoin wallet—called a 'silent payment' (SP) singlesig wallet—through the same screens and file formats that already supported ordinary HD singlesig wallets. It updates dropdown menus, import logic, and several hardware-wallet/file parsers so users can choose between HD and SP when importing. There is no direct evidence in the commit of a security vulnerability; it reads as a feature expansion with explicit guardrails (some importers reject SP if they cannot support it).
Treat as a feature commit, not a security patch. If reviewing for release readiness, verify that the new SP import paths correctly validate descriptor checksums, derivation paths, and network (mainnet/testnet) matching, and that the explicit SP rejection paths in CoboVaultSinglesig/Samourai cannot be bypassed by a malformed file. No immediate security action is indicated by the diff alone.
Security signals we found
New import surface for silent-payment descriptors (sp(...) and tspscan keys) added to multiple parsers
Some importers now reject SINGLE_SP explicitly, which is a defensive boundary
No input validation, memory handling, or cryptographic changes are visible beyond policy/script-type routing
No vendor disclosure, CVE, or researcher attribution present in commit or references
Evidence from the diff
The change extends keystore import paths in Sparrow Wallet to support PolicyType.SINGLE_SP (silent payments) alongside PolicyType.SINGLE_HD. UI panes (DevicePane, FileWalletKeystoreImportPane, MnemonicWalletKeystoreImportPane, Bip39Dialog) now pair ScriptType with PolicyType and pass the selected policy through to wallet/policy construction. Importers for Keystone, Specter DIY, and Coldcard Singlesig gain SP-aware parsing of output descriptors or JSON fields; Cobo Vault and Samourai explicitly throw ImportException for SP. Tests and test fixtures for Keystone and Specter DIY SP imports are added. The diff is purely additive feature work; no bug fix, privilege change, or cryptographic mishandling is visible.
Changed components
DevicePane.javaFileWalletKeystoreImportPane.javaMnemonicWalletKeystoreImportPane.javaCoboVaultSinglesig.javaColdcardSinglesig.javaKeystoneSinglesig.javaSamourai.javaSpecterDIY.javaBip39Dialog.javaKeystoneSinglesigTest.javaSpecterDIYTest.javaInspect captured patch +204 / −117
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java
index befe198..6e5afda 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java
@@ -305,15 +305,34 @@ public class DevicePane extends TitledDescriptionPane {
if(importButton instanceof SplitMenuButton importMenuButton) {
if(wallet.getScriptType() == null) {
- ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH, ScriptType.P2TR};
- for(ScriptType scriptType : scriptTypes) {
- MenuItem item = new MenuItem(scriptType.getDescription());
- final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
- item.setOnAction(event -> {
- importMenuButton.setDisable(true);
- importKeystore(derivation);
- });
- importMenuButton.getItems().add(item);
+ if(wallet.getPolicyType() == null) {
+ List<PolicyAndScriptType> types = new ArrayList<>();
+ for(PolicyType policyType : List.of(PolicyType.SINGLE_HD, PolicyType.SINGLE_SP)) {
+ for(ScriptType scriptType : ScriptType.getAddressableScriptTypes(policyType)) {
+ types.add(new PolicyAndScriptType(policyType, scriptType));
+ }
+ }
+ for(PolicyAndScriptType type : types) {
+ MenuItem item = new MenuItem(type.getDescription());
+ final List<ChildNumber> derivation = type.scriptType().getDefaultDerivation();
+ item.setOnAction(event -> {
+ importMenuButton.setDisable(true);
+ wallet.setPolicyType(type.policyType());
+ importKeystore(derivation);
+ });
+ importMenuButton.getItems().add(item);
+ }
+ } else {
+ List<ScriptType> scriptTypes = ScriptType.getScriptTypesForPolicyType(wallet.getPolicyType());
+ for(ScriptType scriptType : scriptTypes) {
+ MenuItem item = new MenuItem(scriptType.getDescription());
+ final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
+ item.setOnAction(event -> {
+ importMenuButton.setDisable(true);
+ importKeystore(derivation);
+ });
+ importMenuButton.getItems().add(item);
+ }
}
importMenuButton.getItems().add(new SeparatorMenuItem());
MenuItem discoverItem = new MenuItem("Discover Wallet...");
@@ -811,12 +830,13 @@ public class DevicePane extends TitledDescriptionPane {
private void importKeystore(List<ChildNumber> derivation, Keystore keystore) {
if(wallet.getScriptType() == null) {
- ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
+ ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().getFirst().equals(derivation.getFirst())).findFirst().orElse(ScriptType.P2PKH);
+ PolicyType policyType = wallet.getPolicyType() != null ? wallet.getPolicyType() : PolicyType.SINGLE_HD;
wallet.setName(device.getModel().toDisplayString());
- wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setPolicyType(policyType);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
- wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, scriptType, wallet.getKeystores(), null));
+ wallet.setDefaultPolicy(Policy.getPolicy(policyType, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
@@ -1020,7 +1040,7 @@ public class DevicePane extends TitledDescriptionPane {
AppServices.showErrorDialog("No existing wallet found",
Config.get().getServerType() == ServerType.BITCOIN_CORE ? "The configured server type is Bitcoin Core, which does not support wallet discovery.\n\n" +
"You can however import the " + device.getModel().toDisplayString() + " and scan the blockchain by supplying a start date." :
- "Could not find a wallet with existing transactions using the " + device.getModel().toDisplayString() + ".");
+ "Could not find an HD wallet with existing transactions using the " + device.getModel().toDisplayString() + ".");
setDefaultStatus();
importButton.setDisable(false);
}
@@ -1393,4 +1413,10 @@ public class DevicePane extends TitledDescriptionPane {
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES, GET_PRIVATE_KEY, GET_ADDRESS;
}
+
+ protected record PolicyAndScriptType(PolicyType policyType, ScriptType scriptType) {
+ public String getDescription() {
+ return scriptType.getDescription() + (policyType == PolicyType.SINGLE_SP ? " SP" : " HD");
+ }
+ }
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java
index f873229..b938875 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java
@@ -30,8 +30,8 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.util.ArrayList;
import java.util.List;
-import java.util.stream.Collectors;
public class FileWalletKeystoreImportPane extends FileImportPane {
private static final Logger log = LoggerFactory.getLogger(FileWalletKeystoreImportPane.class);
@@ -50,19 +50,27 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
this.fileName = fileName;
this.password = password;
- List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE_HD);
+ List<PolicyAndScriptType> types = new ArrayList<>();
+ for(PolicyType policyType : List.of(PolicyType.SINGLE_HD, PolicyType.SINGLE_SP)) {
+ for(ScriptType scriptType : ScriptType.getAddressableScriptTypes(policyType)) {
+ types.add(new PolicyAndScriptType(policyType, scriptType));
+ }
+ }
+
if(wallets != null && !wallets.isEmpty()) {
- if(wallets.size() == 1 && scriptTypes.contains(wallets.get(0).getScriptType())) {
- Wallet wallet = wallets.get(0);
- wallet.setPolicyType(PolicyType.SINGLE_HD);
- wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, wallet.getScriptType(), wallet.getKeystores(), null));
+ wallets.stream().filter(w -> w.getPolicyType() == null).forEach(w -> w.setPolicyType(PolicyType.SINGLE_HD));
+ List<PolicyAndScriptType> walletTypes = wallets.stream().map(w -> new PolicyAndScriptType(w.getPolicyType(), w.getScriptType())).toList();
+ types.retainAll(walletTypes);
+ if(types.isEmpty()) {
+ throw new ImportException("No singlesig script types present in QR code");
+ }
+
+ if(types.size() == 1) {
+ Wallet wallet = wallets.stream().filter(w -> w.getPolicyType() == types.getFirst().policyType() && w.getScriptType() == types.getFirst().scriptType()).findFirst().orElseThrow(ImportException::new);
+ wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), null));
wallet.setName(importer.getName());
- EventManager.get().post(new WalletImportEvent(wallets.get(0)));
- } else {
- scriptTypes.retainAll(wallets.stream().map(Wallet::getScriptType).collect(Collectors.toList()));
- if(scriptTypes.isEmpty()) {
- throw new ImportException("No singlesig script types present in QR code");
- }
+ EventManager.get().post(new WalletImportEvent(wallet));
+ return;
}
} else {
try {
@@ -72,58 +80,61 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
}
}
- setContent(getScriptTypeEntry(scriptTypes));
+ setContent(getScriptTypeEntry(types));
setExpanded(true);
importButton.setDisable(true);
}
- private void importWallet(ScriptType scriptType) throws ImportException {
+ private void importWallet(PolicyAndScriptType type) throws ImportException {
+ PolicyType policyType = type.policyType();
+ ScriptType scriptType = type.scriptType();
+
if(wallets != null && !wallets.isEmpty()) {
- Wallet wallet = wallets.stream().filter(wallet1 -> wallet1.getScriptType() == scriptType).findFirst().orElseThrow(ImportException::new);
+ Wallet wallet = wallets.stream().filter(w -> w.getPolicyType() == policyType && w.getScriptType() == scriptType).findFirst().orElseThrow(ImportException::new);
wallet.setName(importer.getName());
- wallet.setPolicyType(PolicyType.SINGLE_HD);
- wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, wallet.getScriptType(), wallet.getKeystores(), null));
+ wallet.setDefaultPolicy(Policy.getPolicy(policyType, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
- Keystore keystore = importer.getKeystore(PolicyType.SINGLE_HD, scriptType, bais, password);
+ Keystore keystore = importer.getKeystore(policyType, scriptType, bais, password);
Wallet wallet = new Wallet();
wallet.setName(Files.getNameWithoutExtension(fileName));
- wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setPolicyType(policyType);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
- wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, scriptType, wallet.getKeystores(), null));
+ wallet.setDefaultPolicy(Policy.getPolicy(policyType, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
}
}
- private Node getScriptTypeEntry(List<ScriptType> scriptTypes) {
- Label label = new Label("Script Type:");
+ private Node getScriptTypeEntry(List<PolicyAndScriptType> types) {
+ Label label = new Label("Type:");
HBox fieldBox = new HBox(5);
fieldBox.setAlignment(Pos.CENTER_RIGHT);
- ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(scriptTypes));
- if(scriptTypes.contains(ScriptType.P2WPKH)) {
- scriptTypeComboBox.setValue(ScriptType.P2WPKH);
+ ComboBox<PolicyAndScriptType> comboBox = new ComboBox<>(FXCollections.observableArrayList(types));
+ PolicyAndScriptType defaultType = new PolicyAndScriptType(PolicyType.SINGLE_HD, ScriptType.P2WPKH);
+ if(types.contains(defaultType)) {
+ comboBox.setValue(defaultType);
}
- scriptTypeComboBox.setConverter(new StringConverter<>() {
+ comboBox.setConverter(new StringConverter<>() {
@Override
- public String toString(ScriptType scriptType) {
- return scriptType == null ? "" : scriptType.getDescription();
+ public String toString(PolicyAndScriptType type) {
+ return type == null ? "" : type.getDescription();
}
@Override
- public ScriptType fromString(String string) {
+ public PolicyAndScriptType fromString(String string) {
return null;
}
});
- scriptTypeComboBox.setMaxWidth(170);
+ comboBox.setMaxWidth(220);
HelpLabel helpLabel = new HelpLabel();
- helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
- fieldBox.getChildren().addAll(scriptTypeComboBox, helpLabel);
+ helpLabel.setHelpText("Native Segwit is usually the best choice for new wallets.\nTaproot is newer and supports both HD and SP (silent payments) wallets.\nNested Segwit and Legacy are useful for recovering older wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
+ fieldBox.getChildren().addAll(comboBox, helpLabel);
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
@@ -133,7 +144,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
showHideLink.setVisible(true);
setExpanded(false);
try {
- importWallet(scriptTypeComboBox.getValue());
+ importWallet(comboBox.getValue());
} catch(ImportException e) {
log.error("Error importing file", e);
String errorMessage = e.getMessage();
@@ -154,8 +165,14 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
- Platform.runLater(scriptTypeComboBox::requestFocus);
+ Platform.runLater(comboBox::requestFocus);
return contentBox;
}
+
+ protected record PolicyAndScriptType(PolicyType policyType, ScriptType scriptType) {
+ public String getDescription() {
+ return scriptType.getDescription() + (policyType == PolicyType.SINGLE_SP ? " SP" : " HD");
+ }
+ }
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicWalletKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicWalletKeystoreImportPane.java
index bdb8cf7..8426d2b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicWalletKeystoreImportPane.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicWalletKeystoreImportPane.java
@@ -115,7 +115,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
for(ScriptType scriptType : ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE_HD)) {
for(List<ChildNumber> derivation : derivations) {
try {
- Wallet wallet = getWallet(scriptType, derivation);
+ Wallet wallet = getWallet(PolicyType.SINGLE_HD, scriptType, derivation);
wallets.add(wallet);
} catch(ImportException e) {
String errorMessage = e.getMessage();
@@ -148,7 +148,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found",
Config.get().getServerType() == ServerType.BITCOIN_CORE ? "The configured server type is Bitcoin Core, which does not support wallet discovery.\n\n" +
"You can however import this wallet and scan the blockchain by supplying a start date. Do you want to import this wallet?" :
- "Could not find a wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES);
+ "Could not find an HD wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
setContent(getScriptTypeEntry());
setExpanded(true);
@@ -163,41 +163,49 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
walletDiscoveryService.start();
}
- private Wallet getWallet(ScriptType scriptType, List<ChildNumber> derivation) throws ImportException {
+ private Wallet getWallet(PolicyType policyType, ScriptType scriptType, List<ChildNumber> derivation) throws ImportException {
Wallet wallet = new Wallet("");
- wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setPolicyType(policyType);
wallet.setScriptType(scriptType);
- Keystore keystore = importer.getKeystore(PolicyType.SINGLE_HD, derivation, wordEntriesProperty.get(), passphraseProperty.get());
+ Keystore keystore = importer.getKeystore(policyType, derivation, wordEntriesProperty.get(), passphraseProperty.get());
wallet.getKeystores().add(keystore);
- wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, scriptType, wallet.getKeystores(), 1));
+ wallet.setDefaultPolicy(Policy.getPolicy(policyType, scriptType, wallet.getKeystores(), 1));
return wallet;
}
private Node getScriptTypeEntry() {
- Label label = new Label("Script Type:");
+ Label label = new Label("Type:");
+
+ List<PolicyAndScriptType> types = new ArrayList<>();
+ for(PolicyType policyType : List.of(PolicyType.SINGLE_HD, PolicyType.SINGLE_SP)) {
+ for(ScriptType scriptType : ScriptType.getAddressableScriptTypes(policyType)) {
+ types.add(new PolicyAndScriptType(policyType, scriptType));
+ }
+ }
HBox fieldBox = new HBox(5);
fieldBox.setAlignment(Pos.CENTER_RIGHT);
- ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE_HD)));
- if(scriptTypeComboBox.getItems().contains(ScriptType.P2WPKH)) {
- scriptTypeComboBox.setValue(ScriptType.P2WPKH);
+ ComboBox<PolicyAndScriptType> comboBox = new ComboBox<>(FXCollections.observableArrayList(types));
+ PolicyAndScriptType defaultType = new PolicyAndScriptType(PolicyType.SINGLE_HD, ScriptType.P2WPKH);
+ if(types.contains(defaultType)) {
+ comboBox.setValue(defaultType);
}
- scriptTypeComboBox.setConverter(new StringConverter<>() {
+ comboBox.setConverter(new StringConverter<>() {
@Override
- public String toString(ScriptType scriptType) {
- return scriptType == null ? "" : scriptType.getDescription();
+ public String toString(PolicyAndScriptType type) {
+ return type == null ? "" : type.getDescription();
}
@Override
- public ScriptType fromString(String string) {
+ public PolicyAndScriptType fromString(String string) {
return null;
}
});
- scriptTypeComboBox.setMaxWidth(170);
+ comboBox.setMaxWidth(220);
HelpLabel helpLabel = new HelpLabel();
- helpLabel.setHelpText("Native Segwit is usually the best choice for new wallets.\nTaproot is a new type useful for specific needs.\nNested Segwit and Legacy are useful for recovering older wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
- fieldBox.getChildren().addAll(scriptTypeComboBox, helpLabel);
+ helpLabel.setHelpText("Native Segwit is usually the best choice for new wallets.\nTaproot is a new type useful for specific needs.\nTaproot Silent Payments creates a silent payment wallet.\nNested Segwit and Legacy are useful for recovering older wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
+ fieldBox.getChildren().addAll(comboBox, helpLabel);
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
@@ -208,8 +216,8 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
showHideLink.setVisible(true);
setExpanded(false);
try {
- ScriptType scriptType = scriptTypeComboBox.getValue();
- Wallet wallet = getWallet(scriptType, scriptType.getDefaultDerivation());
+ PolicyAndScriptType type = comboBox.getValue();
+ Wallet wallet = getWallet(type.policyType(), type.scriptType(), type.scriptType().getDefaultDerivation());
EventManager.get().post(new WalletImportEvent(wallet));
} catch(ImportException e) {
log.error("Error importing mnemonic", e);
@@ -231,4 +239,10 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
return contentBox;
}
+
+ protected record PolicyAndScriptType(PolicyType policyType, ScriptType scriptType) {
+ public String getDescription() {
+ return scriptType.getDescription() + (policyType == PolicyType.SINGLE_SP ? " SP" : " HD");
+ }
+ }
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java b/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java
index ad1ea3f..5819c43 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java
@@ -36,6 +36,10 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
@Override
public Keystore getKeystore(PolicyType policyType, ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
+ if(policyType == PolicyType.SINGLE_SP) {
+ throw new ImportException(getName() + " does not support receiving silent payments");
+ }
+
try {
Gson gson = new Gson();
CoboVaultSinglesigKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), CoboVaultSinglesigKeystore.class);
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java b/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java
index af63973..d17e16d 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java
@@ -62,8 +62,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}.getType();
Map<String, JsonElement> map = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), stringStringMap);
- if (map.get("xfp") == null) {
- throw new ImportException("File was not a valid " + getName() + " wallet export");
+ if(map.get("xfp") == null) {
+ throw new ImportException("Export was not a valid " + getName() + " wallet export");
}
String masterFingerprint = map.get("xfp").getAsString();
@@ -71,7 +71,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
if(policyType == PolicyType.SINGLE_SP) {
JsonElement bip352Element = map.get("bip352");
if(bip352Element == null) {
- throw new ImportException("File does not contain an export for silent payments");
+ throw new ImportException("Export does not contain the spscan value for silent payments");
}
ColdcardKeystore ck = gson.fromJson(bip352Element, ColdcardKeystore.class);
@@ -84,8 +84,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
return keystore;
}
- for (String key : map.keySet()) {
- if (key.startsWith("bip")) {
+ for(String key : map.keySet()) {
+ if(key.startsWith("bip")) {
ColdcardKeystore ck = gson.fromJson(map.get(key), ColdcardKeystore.class);
if(ck.name != null) {
@@ -103,7 +103,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}
}
}
- } catch (Exception e) {
+ } catch(Exception e) {
throw new ImportException("Error getting " + getName() + " keystore", e);
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/KeystoneSinglesig.java b/src/main/java/com/sparrowwallet/sparrow/io/KeystoneSinglesig.java
index c47e8be..485ccb0 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/KeystoneSinglesig.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/KeystoneSinglesig.java
@@ -45,24 +45,22 @@ public class KeystoneSinglesig implements KeystoreFileImport, WalletImport {
throw new IllegalArgumentException("Output descriptor describes a multisig wallet");
}
+ if(policyType == PolicyType.SINGLE_SP && !descriptor.isSilentPayments()) {
+ throw new IllegalArgumentException("Export does not contain the spscan value for silent payments");
+ }
+
if(descriptor.getScriptType() != scriptType) {
throw new IllegalArgumentException("Output descriptor describes a " + descriptor.getScriptType().getDescription() + " wallet");
}
- ExtendedKey xpub = descriptor.getSingletonExtendedPublicKey();
- KeyDerivation keyDerivation = descriptor.getKeyDerivation(xpub);
-
- Keystore keystore = new Keystore();
+ Wallet wallet = descriptor.toWallet();
+ Keystore keystore = wallet.getKeystores().getFirst();
keystore.setLabel(getName());
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setWalletModel(getWalletModel());
- keystore.setKeyDerivation(keyDerivation);
- keystore.setExtendedPublicKey(xpub);
return keystore;
- } catch (IllegalArgumentException e) {
- throw new ImportException("Error getting " + getName() + " keystore - not an output descriptor", e);
- } catch (Exception e) {
+ } catch(Exception e) {
throw new ImportException("Error getting " + getName() + " keystore", e);
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Samourai.java b/src/main/java/com/sparrowwallet/sparrow/io/Samourai.java
index 6b7e2a9..8742c0e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/Samourai.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/Samourai.java
@@ -27,6 +27,10 @@ public class Samourai implements KeystoreFileImport {
@Override
public Keystore getKeystore(PolicyType policyType, ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
+ if(policyType == PolicyType.SINGLE_SP) {
+ throw new ImportException(getName() + " does not support receiving silent payments");
+ }
+
try {
String input = CharStreams.toString(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java b/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java
index ead4e5d..30db6b3 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java
@@ -21,7 +21,7 @@ public class SpecterDIY implements KeystoreFileImport, WalletExport {
public Keystore getKeystore(PolicyType policyType, ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
String text = CharStreams.toString(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
- String outputDesc = "sh(" + text + ")";
+ String outputDesc = policyType == PolicyType.SINGLE_SP ? "sp(" + text + ")" : "sh(" + text + ")";
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(outputDesc);
Wallet wallet = outputDescriptor.toWallet();
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/Bip39Dialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/Bip39Dialog.java
index a1826c5..610bd1a 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/Bip39Dialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/Bip39Dialog.java
@@ -28,7 +28,7 @@ public class Bip39Dialog extends NewWalletDialog {
private final Bip39 importer = new Bip39();
- private final ComboBox<DisplayScriptType> scriptType;
+ private final ComboBox<PolicyAndScriptType> scriptType;
private final TextBox seedWords;
private final TextBox passphrase;
private final Button createWallet;
@@ -68,13 +68,17 @@ public class Bip39Dialog extends NewWalletDialog {
buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel);
setComponent(mainPanel);
- ScriptType.getAddressableScriptTypes(PolicyType.SINGLE_HD).stream().map(DisplayScriptType::new).forEach(scriptType::addItem);
- scriptType.setSelectedItem(new DisplayScriptType(ScriptType.P2WPKH));
+ for(PolicyType policyType : List.of(PolicyType.SINGLE_HD, PolicyType.SINGLE_SP)) {
+ for(ScriptType scriptType : ScriptType.getAddressableScriptTypes(policyType)) {
+ this.scriptType.addItem(new PolicyAndScriptType(policyType, scriptType));
+ }
+ }
+ scriptType.setSelectedItem(new PolicyAndScriptType(PolicyType.SINGLE_HD, ScriptType.P2WPKH));
seedWords.setTextChangeListener((newText, changedByUserInteraction) -> {
try {
String[] words = newText.split("[ \n]");
- importer.getKeystore(PolicyType.SINGLE_HD, scriptType.getSelectedItem().scriptType.getDefaultDerivation(), Arrays.asList(words), passphrase.getText());
+ importer.getKeystore(PolicyType.SINGLE_HD, scriptType.getSelectedItem().scriptType().getDefaultDerivation(), Arrays.asList(words), passphrase.getText());
createWallet.setEnabled(true);
} catch(ImportException e) {
createWallet.setEnabled(false);
@@ -149,44 +153,20 @@ public class Bip39Dialog extends NewWalletDialog {
@Override
protected List<Wallet> getWallets() throws ImportException {
+ PolicyAndScriptType type = scriptType.getSelectedItem();
Wallet wallet = new Wallet(walletName);
- wallet.setPolicyType(PolicyType.SINGLE_HD);
- wallet.setScriptType(scriptType.getSelectedItem().scriptType);
- Keystore keystore = importer.getKeystore(PolicyType.SINGLE_HD, wallet.getScriptType().getDefaultDerivation(), getWords(), passphrase.getText());
+ wallet.setPolicyType(type.policyType());
+ wallet.setScriptType(type.scriptType());
+ Keystore keystore = importer.getKeystore(type.policyType(), wallet.getScriptType().getDefaultDerivation(), getWords(), passphrase.getText());
wallet.getKeystores().add(keystore);
- wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, wallet.getScriptType(), wallet.getKeystores(), 1));
+ wallet.setDefaultPolicy(Policy.getPolicy(type.policyType(), wallet.getScriptType(), wallet.getKeystores(), 1));
return List.of(wallet);
}
- private static final class DisplayScriptType {
- private final ScriptType scriptType;
-
- public DisplayScriptType(ScriptType scriptType) {
- this.scriptType = scriptType;
- }
-
+ private record PolicyAndScriptType(PolicyType policyType, ScriptType scriptType) {
@Override
public String toString() {
- return scriptType.getDescription();
- }
-
- @Override
- public boolean equals(Object o) {
- if(this == o) {
- return true;
- }
- if(o == null || getClass() != o.getClass()) {
- return false;
- }
-
- DisplayScriptType that = (DisplayScriptType) o;
-
- return scriptType == that.scriptType;
- }
-
- @Override
- public int hashCode() {
- return scriptType.hashCode();
+ return scriptType.getDescription() + (policyType == PolicyType.SINGLE_SP ? " SP" : " HD");
}
}
diff --git a/src/test/java/com/sparrowwallet/sparrow/io/KeystoneSinglesigTest.java b/src/test/java/com/sparrowwallet/sparrow/io/KeystoneSinglesigTest.java
index 55cd089..8cf878b 100644
--- a/src/test/java/com/sparrowwallet/sparrow/io/KeystoneSinglesigTest.java
+++ b/src/test/java/com/sparrowwallet/sparrow/io/KeystoneSinglesigTest.java
@@ -1,9 +1,12 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.ExtendedKey;
+import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress;
import com.sparrowwallet.drongo.wallet.Keystore;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -25,4 +28,25 @@ public class KeystoneSinglesigTest extends IoTest {
KeystoneSinglesig keystoneSingleSig = new KeystoneSinglesig();
Assertions.assertThrows(ImportException.class, () -> keystoneSingleSig.getKeystore(PolicyType.SINGLE_HD, ScriptType.P2SH_P2WPKH, getInputStream("keystone-singlesig-keystore-1.txt"), null));
}
+
+ @Test
+ public void testImportSilentPayments() throws ImportException {
+ Network.set(Network.TESTNET);
+ KeystoneSinglesig keystoneSingleSig = new KeystoneSinglesig();
+ Keystore keystore = keystoneSingleSig.getKeystore(PolicyType.SINGLE_SP, ScriptType.P2TR, getInputStream("keystone-singlesig-sp-keystore-1.txt"), null);
+
+ Assertions.assertEquals("Keystone", keystore.getLabel());
+ Assertions.assertEquals("m/352'/1'/0'", keystore.getKeyDerivation().getDerivationPath());
+ Assertions.assertEquals("0f056943", keystore.getKeyDerivation().getMasterFingerprint());
+ Assertions.assertNull(keystore.getExtendedPublicKey());
+ Assertions.assertNotNull(keystore.getSilentPaymentScanAddress());
+ Assertions.assertEquals(SilentPaymentScanAddress.fromKeyString("tspscan1q05wxw5wc7wqmkf8cnfc6ry76qej8vhr3a3mmxmwgv35s0tlw24fs82k0npv2hv6p97s8sd9t7vpf44kluka9w863zjwxzfrym2ay9ccfzt06c4"),
+ keystore.getSilentPaymentScanAddress());
+ Assertions.assertTrue(keystore.isValid());
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ Network.set(null);
+ }
}
diff --git a/src/test/java/com/sparrowwallet/sparrow/io/SpecterDIYTest.java b/src/test/java/com/sparrowwallet/sparrow/io/SpecterDIYTest.java
index 7afeeff..5fcc1f0 100644
--- a/src/test/java/com/sparrowwallet/sparrow/io/SpecterDIYTest.java
+++ b/src/test/java/com/sparrowwallet/sparrow/io/SpecterDIYTest.java
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.junit.jupiter.api.Assertions;
@@ -29,6 +30,23 @@ public class SpecterDIYTest extends IoTest {
Network.set(Network.MAINNET);
}
+ @Test
+ public void testImportSilentPayments() throws ImportException {
+ Network.set(Network.TESTNET);
+ SpecterDIY specterDIY = new SpecterDIY();
+ Keystore keystore = specterDIY.getKeystore(PolicyType.SINGLE_SP, ScriptType.P2TR, getInputStream("specter-diy-sp-keystore.txt"), null);
+
+ Assertions.assertEquals("Specter DIY", keystore.getLabel());
+ Assertions.assertEquals("m/352'/1'/0'", keystore.getKeyDerivation().getDerivationPath());
+ Assertions.assertEquals("0f056943", keystore.getKeyDerivation().getMasterFingerprint());
+ Assertions.assertNull(keystore.getExtendedPublicKey());
+ Assertions.assertNotNull(keystore.getSilentPaymentScanAddress());
+ Assertions.assertEquals(SilentPaymentScanAddress.fromKeyString("tspscan1q05wxw5wc7wqmkf8cnfc6ry76qej8vhr3a3mmxmwgv35s0tlw24fs82k0npv2hv6p97s8sd9t7vpf44kluka9w863zjwxzfrym2ay9ccfzt06c4"),
+ keystore.getSilentPaymentScanAddress());
+ Assertions.assertTrue(keystore.isValid());
+ Network.set(Network.MAINNET);
+ }
+
@Test
public void testExport() throws ExportException, IOException {
OutputDescriptor walletDescriptor = OutputDescriptor.getOutputDescriptor("wsh(sortedmulti(2,[7fd1bbf4/48h/0h/0h/2h]xpub6DnVFCXjZKhSAJw1oGzksdc1CtMxHxqG6DgNSjZHsymMSgcNEb2c3bz5N2bBMEEUFos98CeAWbh1pTMBcJrsKW63icdAQNGT6Aqv1WWrkxg,[8ff26349/48h/0h/0h/2h]xpub6ErPooPdSeBoXVZocBe8EWF9GXjFuV52kme35p4MtrP2SAFdUmgTJM1urrJzSuA44izrEuiQNNdmWEVRaBJcBDcPpnLBR8tP2Pcu2EiyeHu,[ff3305c2/48h/0h/0h/2h]xpub6Dpndp2xurqbfSGhxKVXzk3nJZgah3PdD3qD11KyPicYYBatRxfxqoN7s9tnWKXaz7zhyVqcvnJyak7BVKonW2wTXHd1zNDxJAu8jcxF59j))");
diff --git a/src/test/resources/com/sparrowwallet/sparrow/io/keystone-singlesig-sp-keystore-1.txt b/src/test/resources/com/sparrowwallet/sparrow/io/keystone-singlesig-sp-keystore-1.txt
new file mode 100644
index 0000000..cbbac1a
--- /dev/null
+++ b/src/test/resources/com/sparrowwallet/sparrow/io/keystone-singlesig-sp-keystore-1.txt
@@ -0,0 +1 @@
+sp([0f056943/352h/1h/0h]tspscan1q05wxw5wc7wqmkf8cnfc6ry76qej8vhr3a3mmxmwgv35s0tlw24fs82k0npv2hv6p97s8sd9t7vpf44kluka9w863zjwxzfrym2ay9ccfzt06c4)
\ No newline at end of file
diff --git a/src/test/resources/com/sparrowwallet/sparrow/io/specter-diy-sp-keystore.txt b/src/test/resources/com/sparrowwallet/sparrow/io/specter-diy-sp-keystore.txt
new file mode 100644
index 0000000..452d539
--- /dev/null
+++ b/src/test/resources/com/sparrowwallet/sparrow/io/specter-diy-sp-keystore.txt
@@ -0,0 +1 @@
+[0f056943/352h/1h/0h]tspscan1q05wxw5wc7wqmkf8cnfc6ry76qej8vhr3a3mmxmwgv35s0tlw24fs82k0npv2hv6p97s8sd9t7vpf44kluka9w863zjwxzfrym2ay9ccfzt06c4
\ No newline at end of file
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.