From bc526c90e1a4fa2988df1538d6925ea7fdceb5c3 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Wed, 22 Apr 2026 14:51:52 +0200 Subject: [PATCH] various sp wallet related fixes and improvements --- .../drongo/OutputDescriptor.java | 28 ++++++++++++- .../com/sparrowwallet/drongo/psbt/PSBT.java | 9 ++-- .../SilentPaymentScanAddress.java | 14 +++++++ .../silentpayments/SilentPaymentUtils.java | 8 +++- .../sparrowwallet/drongo/wallet/Keystore.java | 42 ++++++++++++++++--- .../sparrowwallet/drongo/wallet/Wallet.java | 6 ++- .../drongo/wallet/WalletNode.java | 4 ++ 7 files changed, 95 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java index cdd0178..a2b7cd5 100644 --- a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java +++ b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java @@ -671,9 +671,14 @@ public class OutputDescriptor { private static OutputDescriptor parseTwoArgSp(String scanArg, String spendArg, Map annotations) { KeyDerivationAndKey originResult = parseKeyOrigin(scanArg); KeyDerivation keyDerivation = originResult.keyDerivation(); - scanArg = originResult.key(); + if(keyDerivation.getDerivation().size() > 2) { + List accountDerivation = keyDerivation.getDerivation().subList(0, keyDerivation.getDerivation().size() - 2); + if(KeyDerivation.getBip352ScanDerivation(accountDerivation).equals(keyDerivation.getDerivation())) { + keyDerivation = new KeyDerivation(keyDerivation.getMasterFingerprint(), accountDerivation); + } + } - ECKey scanPrivateKey = parseSilentPaymentScanKey(scanArg); + ECKey scanPrivateKey = parseSilentPaymentScanKey(originResult.key()); ECKey spendKey = parseSilentPaymentSpendKey(spendArg); ECKey spendPubKey = spendKey.isPubKeyOnly() ? spendKey : ECKey.fromPublicOnly(spendKey.getPubKey()); @@ -965,6 +970,25 @@ public class OutputDescriptor { return keyBuilder.toString(); } + public static String writeKey(ECKey ecKey, KeyDerivation keyDerivation, boolean addKeyOrigin) { + return writeKey(ecKey, keyDerivation, addKeyOrigin, false); + } + + public static String writeKey(ECKey ecKey, KeyDerivation keyDerivation, boolean addKeyOrigin, boolean useApostrophes) { + StringBuilder keyBuilder = new StringBuilder(); + if(addKeyOrigin && keyDerivation != null && keyDerivation.getMasterFingerprint() != null && keyDerivation.getMasterFingerprint().length() == 8 && Utils.isHex(keyDerivation.getMasterFingerprint())) { + keyBuilder.append("["); + keyBuilder.append(keyDerivation.getMasterFingerprint()); + if(!keyDerivation.getDerivation().isEmpty()) { + keyBuilder.append(KeyDerivation.writePath(keyDerivation.getDerivation(), useApostrophes).substring(1)); + } + keyBuilder.append("]"); + } + keyBuilder.append(ecKey.hasPrivKey() ? ecKey.getPrivateKeyEncoded().toString() : Utils.bytesToHex(ecKey.getPubKey())); + + return keyBuilder.toString(); + } + public static String writeKey(ExtendedKey pubKey, KeyDerivation keyDerivation, String childDerivation, boolean addKeyOrigin, boolean addKey) { return writeKey(pubKey, keyDerivation, childDerivation, addKeyOrigin, addKey, false); } diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java index 86a35a3..3934359 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java @@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.silentpayments.*; import com.sparrowwallet.drongo.wallet.*; @@ -161,7 +162,7 @@ public class PSBT { byte[] silentPaymentsTweak = walletNode.getSilentPaymentTweak(); Map spSpendDerivations = new LinkedHashMap<>(); for(Keystore keystore : signingWallet.getKeystores()) { - if(silentPaymentsTweak != null && keystore.getSilentPaymentScanAddress() != null) { + if(silentPaymentsTweak != null && keystore.getSilentPaymentScanAddress() != null && signingWallet.getPolicyType() == PolicyType.SINGLE_SP) { ECKey spendPubKey = keystore.getSilentPaymentScanAddress().getSpendKey(); KeyDerivation spendKeyDerivation = new KeyDerivation(keystore.getKeyDerivation().getMasterFingerprint(), KeyDerivation.writePath(KeyDerivation.getBip352SpendDerivation(keystore.getKeyDerivation().getDerivation()))); spSpendDerivations.put(spendPubKey, spendKeyDerivation); @@ -209,9 +210,9 @@ public class PSBT { SilentPaymentAddress outputSpAddress = null; Long outputSpLabel = null; for(Keystore keystore : recipientWallet.getKeystores()) { - if(outputNode.getSilentPaymentTweak() != null && keystore.getSilentPaymentScanAddress() != null) { + if(outputNode.getSilentPaymentTweak() != null && keystore.getSilentPaymentScanAddress() != null && recipientWallet.getPolicyType() == PolicyType.SINGLE_SP) { SilentPaymentScanAddress changeAddress = keystore.getSilentPaymentScanAddress().getChangeAddress(); - outputSpAddress = new SilentPaymentAddress(ECKey.fromPublicOnly(changeAddress.getScanKey()), changeAddress.getSpendKey()); + outputSpAddress = changeAddress.getSilentPaymentAddress(); outputSpLabel = 0L; } else { derivedPublicKeys.put(recipientWallet.getScriptType().getOutputKey(recipientWallet.getPolicyType(), keystore.getPubKey(outputNode)), keystore.getKeyDerivation().extend(outputNode.getDerivation())); @@ -630,7 +631,7 @@ public class PSBT { WalletNode walletNode = signingNodes.get(psbtInput); if(walletNode != null && walletNode.getWallet() != null) { for(Keystore keystore : signingWallet.getKeystores()) { - if(psbtInput.getSilentPaymentsTweak() != null && keystore.getSilentPaymentScanAddress() != null) { + if(psbtInput.getSilentPaymentsTweak() != null && keystore.getSilentPaymentScanAddress() != null && signingWallet.getPolicyType() == PolicyType.SINGLE_SP) { ECKey spendPubKey = keystore.getSilentPaymentScanAddress().getSpendKey(); KeyDerivation spendKeyDerivation = new KeyDerivation(keystore.getKeyDerivation().getMasterFingerprint(), KeyDerivation.writePath(KeyDerivation.getBip352SpendDerivation(keystore.getKeyDerivation().getDerivation()))); psbtInput.getSilentPaymentsSpendDerivations().put(spendPubKey, spendKeyDerivation); diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java index 4362282..ff8fd31 100644 --- a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java @@ -56,6 +56,10 @@ public class SilentPaymentScanAddress extends SilentPaymentAddress { return new SilentPaymentScanAddress(scanPrivateKey, spendPublicKey); } + public SilentPaymentAddress getSilentPaymentAddress() { + return new SilentPaymentAddress(ECKey.fromPublicOnly(getScanKey()), getSpendKey()); + } + public SilentPaymentScanAddress copy() { return new SilentPaymentScanAddress(getScanKey(), getSpendKey()); } @@ -68,6 +72,16 @@ public class SilentPaymentScanAddress extends SilentPaymentAddress { return Utils.concat(getScanKey().getPrivKeyBytes(), getSpendKey().getPubKey(true)); } + public static boolean isValid(String encoded) { + try { + fromKeyString(encoded); + } catch(Exception e) { + return false; + } + + return true; + } + public static SilentPaymentScanAddress fromKeyString(String encoded) { Bech32.Bech32Data data = Bech32.decode(encoded, 1023); if(data.encoding != Bech32.Encoding.BECH32M) { diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java index 922fc06..a12ca97 100644 --- a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java @@ -204,10 +204,14 @@ public class SilentPaymentUtils { * @throws InvalidSilentPaymentException if the computed shared secrets or addresses are invalid */ public static Map computeOutputAddresses(List silentPayments, Map utxos) throws InvalidSilentPaymentException { + ECKey summedPrivateKey = getSummedPrivateKey(utxos.values()); + return computeOutputAddresses(silentPayments, summedPrivateKey, utxos.keySet()); + } + + public static Map computeOutputAddresses(List silentPayments, ECKey summedPrivateKey, Set outpoints) throws InvalidSilentPaymentException { Map scanKeyProofs = new LinkedHashMap<>(); SecureRandom random = new SecureRandom(); - ECKey summedPrivateKey = getSummedPrivateKey(utxos.values()); - BigInteger inputHash = getInputHash(utxos.keySet(), summedPrivateKey); + BigInteger inputHash = getInputHash(outpoints, summedPrivateKey); Map> scanKeyGroups = getScanKeyGroups(silentPayments); for(Map.Entry> scanKeyGroup : scanKeyGroups.entrySet()) { ECKey scanKey = scanKeyGroup.getKey(); diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java b/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java index 60563c1..02c3c00 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java @@ -37,6 +37,7 @@ public class Keystore extends Persistable { //Avoid performing repeated expensive seed derivation checks private transient boolean extendedPublicKeyChecked; + private transient boolean silentPaymentScanAddressChecked; public Keystore() { this(DEFAULT_LABEL); @@ -112,6 +113,7 @@ public class Keystore extends Persistable { public void setSilentPaymentScanAddress(SilentPaymentScanAddress silentPaymentScanAddress) { this.silentPaymentScanAddress = silentPaymentScanAddress; + this.silentPaymentScanAddressChecked = false; } public byte[] getDeviceRegistration() { @@ -227,10 +229,16 @@ public class Keystore extends Persistable { } public ECKey getKey(WalletNode walletNode) throws MnemonicException { - if(silentPaymentScanAddress != null && walletNode.getSilentPaymentTweak() != null) { + if(silentPaymentScanAddress != null && walletNode.getWallet().getPolicyType() == PolicyType.SINGLE_SP) { ECKey spendPrivKey = getSpendPrivateKey(Collections.emptyMap()); - ECKey tweakKey = ECKey.fromPrivate(walletNode.getSilentPaymentTweak()); - return spendPrivKey.addPrivate(tweakKey); + byte[] tweak = walletNode.getSilentPaymentTweak(); + if(tweak == null) { + if(walletNode.isPurposeNode()) { + return spendPrivKey; + } + throw new IllegalStateException("Silent payment tweak is required for address node " + walletNode.getDerivationPath()); + } + return spendPrivKey.addPrivate(ECKey.fromPrivate(tweak)); } if(source == KeystoreSource.SW_PAYMENT_CODE) { @@ -256,9 +264,16 @@ public class Keystore extends Persistable { } public ECKey getPubKey(WalletNode walletNode) { - if(silentPaymentScanAddress != null && walletNode.getSilentPaymentTweak() != null) { + if(silentPaymentScanAddress != null && walletNode.getWallet().getPolicyType() == PolicyType.SINGLE_SP) { ECKey spendKey = silentPaymentScanAddress.getSpendKey(); - ECKey tweakPoint = ECKey.fromPublicOnly(ECKey.fromPrivate(walletNode.getSilentPaymentTweak())); + byte[] tweak = walletNode.getSilentPaymentTweak(); + if(tweak == null) { + if(walletNode.isPurposeNode()) { + return spendKey; + } + throw new IllegalStateException("Silent payment tweak is required for address node " + walletNode.getDerivationPath()); + } + ECKey tweakPoint = ECKey.fromPublicOnly(ECKey.fromPrivate(tweak)); return spendKey.add(tweakPoint, true); } @@ -382,7 +397,7 @@ public class Keystore extends Persistable { throw new InvalidKeystoreException("Source of " + source + " but no seed or master private key is present"); } - if(!extendedPublicKeyChecked && ((seed != null && !seed.isEncrypted()) || (masterPrivateExtendedKey != null && !masterPrivateExtendedKey.isEncrypted()))) { + if(!extendedPublicKeyChecked && extendedPublicKey != null && ((seed != null && !seed.isEncrypted()) || (masterPrivateExtendedKey != null && !masterPrivateExtendedKey.isEncrypted()))) { try { List derivation = getKeyDerivation().getDerivation(); DeterministicKey derivedKey = getExtendedMasterPrivateKey().getKey(derivation); @@ -396,6 +411,21 @@ public class Keystore extends Persistable { throw new InvalidKeystoreException("Invalid mnemonic specified for seed", e); } } + + if(!silentPaymentScanAddressChecked && silentPaymentScanAddress != null && ((seed != null && !seed.isEncrypted()) || (masterPrivateExtendedKey != null && !masterPrivateExtendedKey.isEncrypted()))) { + try { + List derivation = getKeyDerivation().getDerivation(); + DeterministicKey derivedScanKey = getExtendedMasterPrivateKey().getKey(KeyDerivation.getBip352ScanDerivation(derivation)); + DeterministicKey derivedSpendKey = getExtendedMasterPrivateKey().getKey(KeyDerivation.getBip352SpendDerivation(derivation)); + SilentPaymentScanAddress derivedScanAddress = new SilentPaymentScanAddress(derivedScanKey, derivedSpendKey); + if(!derivedScanAddress.equals(getSilentPaymentScanAddress())) { + throw new InvalidKeystoreException("Specified silent payments scan address does not match scan and spend keys derived from seed"); + } + silentPaymentScanAddressChecked = true; + } catch(MnemonicException e) { + throw new InvalidKeystoreException("Invalid mnemonic specified for seed", e); + } + } } if(source == KeystoreSource.SW_PAYMENT_CODE) { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index 1fda8d3..02cf63c 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -150,12 +150,14 @@ public class Wallet extends Persistable implements Comparable { Keystore derivedKeystore = keystore.hasSeed() ? Keystore.fromSeed(keystore.getSeed(), childDerivation) : Keystore.fromMasterPrivateExtendedKey(keystore.getMasterPrivateExtendedKey(), childDerivation); keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); + keystore.setSilentPaymentScanAddress(derivedKeystore.getSilentPaymentScanAddress()); } catch(Exception e) { throw new IllegalStateException("Cannot derive keystore for " + standardAccount + " account", e); } } else { keystore.setKeyDerivation(new KeyDerivation(null, KeyDerivation.writePath(childDerivation))); keystore.setExtendedPublicKey(null); + keystore.setSilentPaymentScanAddress(null); } } @@ -552,7 +554,7 @@ public class Wallet extends Persistable implements Comparable { } public int getGapLimit() { - return gapLimit == null ? DEFAULT_LOOKAHEAD : gapLimit; + return policyType == PolicyType.SINGLE_SP ? 0 : (gapLimit == null ? DEFAULT_LOOKAHEAD : gapLimit); } public void gapLimit(Integer gapLimit) { @@ -1732,7 +1734,7 @@ public class Wallet extends Persistable implements Comparable { PSBTInput psbtInput = signingEntry.getKey(); if(!psbtInput.isSigned()) { - if(psbtInput.getSilentPaymentsTweak() != null && keystore.getSilentPaymentScanAddress() != null) { + if(psbtInput.getSilentPaymentsTweak() != null && keystore.getSilentPaymentScanAddress() != null && signingWallet.getPolicyType() == PolicyType.SINGLE_SP) { ECKey spendPrivKey = keystore.getSpendPrivateKey(psbtInput.getSilentPaymentsSpendDerivations()); psbtInput.signSilentPayments(spendPrivKey); } else { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java index 0617e9b..11e8064 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java @@ -99,6 +99,10 @@ public class WalletNode extends Persistable implements Comparable { return derivation; } + public boolean isPurposeNode() { + return getDerivation().size() == 1; + } + public String getLabel() { return label; }