recognise a silent payments descriptor when matching label origins and reading a descriptor pdf
What changed, and why it matters
This commit adds support for a newer Bitcoin address format called 'silent payments' when Sparrow Wallet reads wallet labels from files or PDFs. Before this change, the wallet could fail to recognize silent-payment descriptors, so labels tied to those addresses might not be imported or matched correctly. There is no direct evidence this is a security vulnerability—no funds could be stolen—but it is a correctness fix that prevents silent-payment users from silently losing label metadata.
Treat as a feature/correctness fix rather than a security patch. Users relying on silent payments should upgrade to ensure wallet labels import correctly from PDFs and label files. No urgent security action is indicated by the diff alone.
Security signals we found
Silent payment descriptor parsing now recognized in PDF import path
Origin matching extended to include silent payment scan addresses
New test cases assert label import/round-trip for silent payment wallets
Evidence from the diff
The patch updates the drongo submodule and modifies three Java files. PdfUtils now accepts lines that are silent-payment descriptors (OutputDescriptor.isSilentPaymentDescriptor) when extracting an output descriptor from a PDF. WalletLabels.fromOutputDescriptor now includes silent-payment scan addresses (getSilentPaymentScanAddresses) when building the Origin’s key-derivation set, and fromString treats silent-payment origins as ScriptType.P2TR. Tests are added for importing and round-tripping labels with silent-payment origins. The change is purely additive recognition logic; no cryptographic or network code is altered.
Changed components
src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.javasrc/main/java/com/sparrowwallet/sparrow/io/WalletLabels.javasrc/test/java/com/sparrowwallet/sparrow/io/WalletLabelsTest.javadrongo submoduleInspect captured patch +87 / −4
### drongo
@@ -1 +1 @@
-Subproject commit 7ed3d289d6a1e6e570fbfbd82b55fc2cec21e8ed
+Subproject commit f699f8131226f419395fb597635916624d605265
### src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java
@@ -104,7 +104,7 @@ public static OutputDescriptor getOutputDescriptor(InputStream inputStream) thro
String line = scanner.nextLine().trim();
if(descriptor != null) {
descriptor += line;
- } else if(ScriptType.fromDescriptor(line) != null) {
+ } else if(ScriptType.fromDescriptor(line) != null || OutputDescriptor.isSilentPaymentDescriptor(line)) {
descriptor = line;
}
}
### src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java
@@ -577,15 +577,15 @@ public int hashCode() {
public static Origin fromOutputDescriptor(OutputDescriptor outputDescriptor) {
Origin origin = new Origin();
origin.scriptType = outputDescriptor.getScriptType();
- origin.keyDerivations = outputDescriptor.getExtendedPublicKeysMap().values().stream()
+ origin.keyDerivations = Stream.concat(outputDescriptor.getExtendedPublicKeysMap().values().stream(), outputDescriptor.getSilentPaymentScanAddresses().values().stream())
.map(keyDerivation -> new KeyDerivation(keyDerivation.getMasterFingerprint(), KeyDerivation.writePath(keyDerivation.getDerivation())))
.collect(Collectors.toCollection(HashSet::new));
return origin;
}
public static Origin fromString(String strOrigin) {
Origin origin = new Origin();
- origin.scriptType = ScriptType.fromDescriptor(strOrigin);
+ origin.scriptType = OutputDescriptor.isSilentPaymentDescriptor(strOrigin) ? ScriptType.P2TR : ScriptType.fromDescriptor(strOrigin);
origin.keyDerivations = new HashSet<>();
Matcher keyOriginMatcher = KEY_ORIGIN_PATTERN.matcher(strOrigin);
while(keyOriginMatcher.find()) {
### src/test/java/com/sparrowwallet/sparrow/io/WalletLabelsTest.java
@@ -7,13 +7,15 @@
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
+import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.wallet.WalletForm;
@@ -39,6 +41,10 @@ public class WalletLabelsTest {
private static final String XPUB = "xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V";
private static final String MASTER_FINGERPRINT = "73c5da0a";
private static final String ORIGIN = "wpkh([73c5da0a/84h/0h/0h])";
+ //Silent payments scan address at m/352'/0'/0' of the BIP39 mnemonic with fingerprint 60bcd3a7
+ private static final String SP_SCAN_ADDRESS = "spscan1qu6d9s9lfd3a99nckpjw7as602lg0950wvcfwg7g4kakhsp32r57qx4853d0ylm42uewydgx6xgz0v20hgthsk2kr84f96jls3q0jywktrv8us5";
+ private static final String SP_MASTER_FINGERPRINT = "60bcd3a7";
+ private static final byte[] SP_TWEAK = Utils.hexToBytes("1111111111111111111111111111111111111111111111111111111111111111");
@TempDir
private static Path tempHome;
@@ -144,6 +150,46 @@ public void testImport() throws Exception {
Assertions.assertNull(testWallet.fundingTxo1.getStatus(), "Spendable true should thaw a frozen output");
}
+ @Test
+ public void testImportSilentPayments() throws Exception {
+ SpTestWallet testWallet = createSpTestWallet();
+
+ String fundingTxid = testWallet.fundingBlkTx.getHashAsString();
+ String jsonl = String.join("\n",
+ "{\"type\":\"tx\",\"ref\":\"" + fundingTxid + "\",\"label\":\"Funding transaction\",\"origin\":\"sp([60bcd3a7/352h/0h/0h])\"}",
+ "{\"type\":\"addr\",\"ref\":\"" + testWallet.receiveNode.getAddress() + "\",\"label\":\"Primary address\",\"origin\":\"sp([60BCD3A7/352'/0'/0'])\"}",
+ "{\"type\":\"output\",\"ref\":\"" + fundingTxid + ":0\",\"label\":\"Received coins\",\"origin\":\"sp([00000001/352h/0h/0h])\"}");
+
+ WalletLabels walletLabels = new WalletLabels(List.of(testWallet.walletForm));
+ walletLabels.importWallet(new ByteArrayInputStream(jsonl.getBytes(StandardCharsets.UTF_8)), null);
+
+ Assertions.assertEquals("Funding transaction", testWallet.fundingBlkTx.getLabel());
+ Assertions.assertEquals("Primary address", testWallet.receiveNode.getLabel());
+ Assertions.assertNull(testWallet.fundingTxo.getLabel(), "Mismatched fingerprint should not apply");
+ }
+
+ @Test
+ public void testSilentPaymentsRoundTrip() throws Exception {
+ SpTestWallet sourceWallet = createSpTestWallet();
+ sourceWallet.fundingBlkTx.setLabel("Funding transaction");
+ sourceWallet.receiveNode.setLabel("Primary address");
+ sourceWallet.fundingTxo.setLabel("Received coins");
+
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ new WalletLabels(List.of(sourceWallet.walletForm)).exportWallet(sourceWallet.wallet, outputStream, null);
+
+ //Importing a keystore label requires the AppServices singleton, which is not available outside the UI
+ String exported = outputStream.toString(StandardCharsets.UTF_8).lines().filter(line -> !line.contains("\"type\":\"spscan\"")).collect(Collectors.joining("\n"));
+ Assertions.assertTrue(exported.contains("\"origin\":\"sp([60bcd3a7/352h/0h/0h])\""), "Exported labels should carry the silent payments origin");
+
+ SpTestWallet destinationWallet = createSpTestWallet();
+ new WalletLabels(List.of(destinationWallet.walletForm)).importWallet(new ByteArrayInputStream(exported.getBytes(StandardCharsets.UTF_8)), null);
+
+ Assertions.assertEquals("Funding transaction", destinationWallet.fundingBlkTx.getLabel());
+ Assertions.assertEquals("Primary address", destinationWallet.receiveNode.getLabel());
+ Assertions.assertEquals("Received coins", destinationWallet.fundingTxo.getLabel());
+ }
+
@Test
public void testRoundTrip() throws Exception {
TestWallet sourceWallet = createTestWallet();
@@ -235,6 +281,43 @@ private TestWallet createTestWallet() throws Exception {
return new TestWallet(walletForm, wallet, receiveNode0, receiveNode1, fundingBlkTx, spendingBlkTx, fundingTxo0, fundingTxo1, spendingTxi);
}
+ private SpTestWallet createSpTestWallet() throws Exception {
+ Wallet wallet = new Wallet("SP Labels Test");
+ wallet.setPolicyType(PolicyType.SINGLE_SP);
+ wallet.setScriptType(ScriptType.P2TR);
+
+ Keystore keystore = new Keystore("Test Keystore");
+ keystore.setSource(KeystoreSource.SW_WATCH);
+ keystore.setWalletModel(WalletModel.SPARROW);
+ //Use hardened notation to ensure origin matching normalizes the wallet-side derivation
+ keystore.setKeyDerivation(new KeyDerivation(SP_MASTER_FINGERPRINT, "m/352h/0h/0h"));
+ keystore.setSilentPaymentScanAddress(SilentPaymentScanAddress.fromKeyString(SP_SCAN_ADDRESS));
+ wallet.getKeystores().add(keystore);
+ wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_SP, ScriptType.P2TR, wallet.getKeystores(), null));
+ wallet.setStoredBlockHeight(850010);
+ Assertions.assertTrue(wallet.isValid());
+
+ WalletNode receiveNode = wallet.getNode(KeyPurpose.RECEIVE).addSilentPaymentChild(wallet, 0, SP_TWEAK);
+
+ Date fundingDate = new Date(1700000000000L);
+ Transaction fundingTx = new Transaction();
+ fundingTx.addInput(Sha256Hash.wrap("0000000000000000000000000000000000000000000000000000000000000001"), 0, new Script(new byte[0]));
+ fundingTx.addOutput(100000L, receiveNode.getAddress());
+ BlockTransaction fundingBlkTx = new BlockTransaction(fundingTx.getTxId(), 850000, fundingDate, null, fundingTx);
+ wallet.updateTransactions(Map.of(fundingTx.getTxId(), fundingBlkTx));
+
+ BlockTransactionHashIndex fundingTxo = new BlockTransactionHashIndex(fundingTx.getTxId(), 850000, fundingDate, null, 0, 100000L);
+ receiveNode.getTransactionOutputs().add(fundingTxo);
+
+ Storage storage = new Storage(PersistenceType.JSON, new File(tempHome.toFile(), "sp-labels-test.json"));
+ WalletForm walletForm = new WalletForm(storage, wallet);
+
+ return new SpTestWallet(walletForm, wallet, receiveNode, fundingBlkTx, fundingTxo);
+ }
+
+ private record SpTestWallet(WalletForm walletForm, Wallet wallet, WalletNode receiveNode, BlockTransaction fundingBlkTx, BlockTransactionHashIndex fundingTxo) {
+ }
+
private static class TestWallet {
final WalletForm walletForm;
final Wallet wallet;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.