diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java index 0d44d0b..8f658b6 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java @@ -512,6 +512,12 @@ public class PSBT { if(!input.getSilentPaymentsDLEQProofs().isEmpty()) { throw new PSBTParseException("PSBT_IN_SP_DLEQ is not allowed in PSBTv0"); } + if(!input.getSilentPaymentsSpendDerivations().isEmpty()) { + throw new PSBTParseException("PSBT_IN_SP_SPEND_BIP32_DERIVATION is not allowed in PSBTv0"); + } + if(input.getSilentPaymentsTweak() != null) { + throw new PSBTParseException("PSBT_IN_SP_TWEAK is not allowed in PSBTv0"); + } } else if(getPsbtVersion() >= 2) { if(input.prevTxid() == null) { throw new PSBTParseException("PSBT_IN_PREV_TXID is required in PSBTv2"); @@ -885,7 +891,8 @@ public class PSBT { List inputEntries = psbtInput.getInputEntries(getPsbtVersion()); for(PSBTEntry entry : inputEntries) { if((includeXpubs || (entry.getKeyType() != PSBT_IN_BIP32_DERIVATION && entry.getKeyType() != PSBT_IN_PROPRIETARY - && entry.getKeyType() != PSBT_IN_TAP_INTERNAL_KEY && entry.getKeyType() != PSBT_IN_TAP_BIP32_DERIVATION)) + && entry.getKeyType() != PSBT_IN_TAP_INTERNAL_KEY && entry.getKeyType() != PSBT_IN_TAP_BIP32_DERIVATION + && entry.getKeyType() != PSBT_IN_SP_SPEND_BIP32_DERIVATION)) && (includeNonWitnessUtxos || entry.getKeyType() != PSBT_IN_NON_WITNESS_UTXO)) { entry.serializeToStream(baos); } @@ -1303,6 +1310,7 @@ public class PSBT { public PSBT getForExport() { boolean hasSilentPayments = getPsbtOutputs().stream().anyMatch(psbtOutput -> psbtOutput.getSilentPaymentAddress() != null); + hasSilentPayments |= getPsbtInputs().stream().anyMatch(psbtInput -> psbtInput.getSilentPaymentsTweak() != null); //Export as PSBTv0 unless silent payments are present if(!hasSilentPayments && getPsbtVersion() >= 2) { diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java index 49bce68..c71bb57 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java @@ -41,6 +41,8 @@ public class PSBTInput { public static final byte PSBT_IN_TAP_INTERNAL_KEY = 0x17; public static final byte PSBT_IN_SP_ECDH_SHARE = 0x1d; public static final byte PSBT_IN_SP_DLEQ = 0x1e; + public static final byte PSBT_IN_SP_SPEND_BIP32_DERIVATION = 0x1f; + public static final byte PSBT_IN_SP_TWEAK = 0x20; public static final byte PSBT_IN_PROPRIETARY = (byte)0xfc; private final PSBT psbt; @@ -71,6 +73,8 @@ public class PSBTInput { private Long requiredHeightLocktime; private final Map silentPaymentsEcdhShares = new LinkedHashMap<>(); private final Map silentPaymentsDLEQProofs = new LinkedHashMap<>(); + private final Map silentPaymentsSpendDerivations = new LinkedHashMap<>(); + private byte[] silentPaymentsTweak; private int index; @@ -357,6 +361,21 @@ public class PSBTInput { this.silentPaymentsDLEQProofs.put(inputProofScanKey, inputDleqProof); log.debug("Found input silent payments DLEQ proof for scan key: " + Utils.bytesToHex(entry.getKeyData())); break; + case PSBT_IN_SP_SPEND_BIP32_DERIVATION: + entry.checkOneBytePlusPubKey(); + ECKey spSpendPubKey = ECKey.fromPublicOnly(entry.getKeyData()); + KeyDerivation spSpendKeyDerivation = PSBTEntry.parseKeyDerivation(entry.getData()); + this.silentPaymentsSpendDerivations.put(spSpendPubKey, spSpendKeyDerivation); + log.debug("Found input silent payments BIP32 derivation for spend key: " + Utils.bytesToHex(entry.getKeyData())); + break; + case PSBT_IN_SP_TWEAK: + entry.checkOneByteKey(); + if(entry.getData().length != 32) { + throw new PSBTParseException("PSBT input silent payments tweak must be 32 bytes"); + } + this.silentPaymentsTweak = entry.getData(); + log.debug("Found input silent payments tweak"); + break; case PSBT_IN_PROPRIETARY: this.proprietary.put(Utils.bytesToHex(entry.getKeyData()), Utils.bytesToHex(entry.getData())); log.debug("Found proprietary input " + Utils.bytesToHex(entry.getKeyData()) + ": " + Utils.bytesToHex(entry.getData())); @@ -466,6 +485,12 @@ public class PSBTInput { for(Map.Entry entry : silentPaymentsDLEQProofs.entrySet()) { entries.add(populateEntry(PSBT_IN_SP_DLEQ, entry.getKey().getPubKey(), entry.getValue().getBytes())); } + for(Map.Entry entry : silentPaymentsSpendDerivations.entrySet()) { + entries.add(populateEntry(PSBT_IN_SP_SPEND_BIP32_DERIVATION, entry.getKey().getPubKey(), serializeKeyDerivation(entry.getValue()))); + } + if(silentPaymentsTweak != null) { + entries.add(populateEntry(PSBT_IN_SP_TWEAK, null, silentPaymentsTweak)); + } } for(Map.Entry entry : proprietary.entrySet()) { @@ -557,6 +582,12 @@ public class PSBTInput { silentPaymentsEcdhShares.putAll(psbtInput.silentPaymentsEcdhShares); silentPaymentsDLEQProofs.putAll(psbtInput.silentPaymentsDLEQProofs); + silentPaymentsSpendDerivations.putAll(psbtInput.silentPaymentsSpendDerivations); + + if(psbtInput.silentPaymentsTweak != null) { + silentPaymentsTweak = psbtInput.silentPaymentsTweak; + } + proprietary.putAll(psbtInput.proprietary); if(psbtInput.tapKeyPathSignature != null) { @@ -796,6 +827,18 @@ public class PSBTInput { return silentPaymentsDLEQProofs; } + public Map getSilentPaymentsSpendDerivations() { + return silentPaymentsSpendDerivations; + } + + public byte[] getSilentPaymentsTweak() { + return silentPaymentsTweak; + } + + public void setSilentPaymentsTweak(byte[] silentPaymentsTweak) { + this.silentPaymentsTweak = silentPaymentsTweak; + } + public boolean isSigned() { if(getTapKeyPathSignature() != null) { return true; @@ -833,6 +876,26 @@ public class PSBTInput { return SigHash.ALL; } + public boolean signSilentPayments(ECKey spendPrivateKey) { + if(getSilentPaymentsTweak() == null || getWitnessUtxo() == null) { + return false; + } + + ECKey tweakKey = ECKey.fromPrivate(getSilentPaymentsTweak()); + ECKey tweakedKey = spendPrivateKey.addPrivate(tweakKey); + + if(tweakedKey.hasOddYCoord()) { + tweakedKey = tweakedKey.negatePrivate(); + } + + ECKey outputKey = ScriptType.P2TR.getPublicKeyFromScript(getWitnessUtxo().getScript()); + if(!Arrays.equals(tweakedKey.getPubKeyXCoord(), outputKey.getPubKeyXCoord())) { + throw new IllegalStateException("Tweaked spend key does not match output key"); + } + + return sign(tweakedKey); + } + public boolean sign(ECKey privKey) { return sign(new PSBTInputSigner() { @Override @@ -1018,6 +1081,10 @@ public class PSBTInput { proprietary.clear(); tapDerivedPublicKeys.clear(); tapKeyPathSignature = null; + silentPaymentsEcdhShares.clear(); + silentPaymentsDLEQProofs.clear(); + silentPaymentsSpendDerivations.clear(); + silentPaymentsTweak = null; } private Sha256Hash getHashForSignature(Script connectedScript, SigHash localSigHash) { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java b/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java index f986864..4eda1b6 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java @@ -13,8 +13,7 @@ import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.List; +import java.util.*; public class Keystore extends Persistable { private static final Logger log = LoggerFactory.getLogger(Keystore.class); @@ -292,6 +291,33 @@ public class Keystore extends Persistable { return null; } + public ECKey getSpendPrivateKey(Map spendDerivations) throws MnemonicException { + String masterFingerprint = getKeyDerivation().getMasterFingerprint(); + for(Map.Entry entry : spendDerivations.entrySet()) { + if(masterFingerprint.equals(entry.getValue().getMasterFingerprint())) { + DeterministicKey derivedKey = getExtendedMasterPrivateKey().getKey(entry.getValue().getDerivation()); + ECKey spendPrivKey = ECKey.fromPrivate(derivedKey.getPrivKeyBytes(), true); + + if(!Arrays.equals(spendPrivKey.getPubKey(), entry.getKey().getPubKey())) { + throw new IllegalStateException("Derived spend private key does not match PSBT spend public key"); + } + + return spendPrivKey; + } + } + + List spendDerivation = KeyDerivation.getBip352SpendDerivation(getKeyDerivation().getDerivation()); + DeterministicKey derivedKey = getExtendedMasterPrivateKey().getKey(spendDerivation); + ECKey spendPrivKey = ECKey.fromPrivate(derivedKey.getPrivKeyBytes(), true); + + ECKey expectedSpendPubKey = getSilentPaymentScanAddress().getSpendKey(); + if(!Arrays.equals(spendPrivKey.getPubKey(), expectedSpendPubKey.getPubKey())) { + throw new IllegalStateException("Derived spend private key does not match keystore spend public key"); + } + + return spendPrivKey; + } + public boolean isValid() { try { checkKeystore(); diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index 2270aff..2870050 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -1729,11 +1729,16 @@ public class Wallet extends Persistable implements Comparable { Wallet signingWallet = signingEntry.getValue().getWallet(); for(Keystore keystore : signingWallet.getKeystores()) { if(keystore.hasPrivateKey()) { - ECKey privKey = signingWallet.getScriptType().getOutputKey(keystore.getKey(signingEntry.getValue())); PSBTInput psbtInput = signingEntry.getKey(); if(!psbtInput.isSigned()) { - psbtInput.sign(privKey); + if(psbtInput.getSilentPaymentsTweak() != null && keystore.getSilentPaymentScanAddress() != null) { + ECKey spendPrivKey = keystore.getSpendPrivateKey(psbtInput.getSilentPaymentsSpendDerivations()); + psbtInput.signSilentPayments(spendPrivKey); + } else { + ECKey privKey = signingWallet.getScriptType().getOutputKey(keystore.getKey(signingEntry.getValue())); + psbtInput.sign(privKey); + } } } } diff --git a/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java b/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java index 44539b6..379318c 100644 --- a/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java +++ b/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java @@ -906,6 +906,133 @@ public class PSBTTest { parsed.validateSilentPayments(inputPublicKeys); } + @Test + public void testSpSpendFieldsRoundTrip() throws PSBTParseException { + Transaction transaction = new Transaction(); + transaction.setVersion(2); + transaction.addInput(Sha256Hash.wrap("75ddabb27b8845f5247975c8a5ba7c6f336c4570708ebe230caf6db5217ae858"), 0, new Script(new byte[0])); + byte[] outputXCoord = Utils.hexToBytes("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); + transaction.addOutput(100000L, ScriptType.P2TR.getOutputScript(outputXCoord)); + + PSBT psbt = new PSBT(transaction); + psbt.convertVersion(2); + + ECKey spendPubKey = ECKey.fromPrivate(Utils.hexToBytes("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"), true); + KeyDerivation spendKeyDerivation = new KeyDerivation("deadbeef", "m/352'/0'/0'/0'/0"); + psbt.getPsbtInputs().getFirst().getSilentPaymentsSpendDerivations().put(ECKey.fromPublicOnly(spendPubKey), spendKeyDerivation); + + byte[] tweak = Utils.hexToBytes("aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"); + psbt.getPsbtInputs().getFirst().setSilentPaymentsTweak(tweak); + + String serialized = psbt.toBase64String(); + PSBT reparsed = PSBT.fromString(serialized); + + Assertions.assertEquals(1, reparsed.getPsbtInputs().get(0).getSilentPaymentsSpendDerivations().size()); + Map.Entry entry = reparsed.getPsbtInputs().get(0).getSilentPaymentsSpendDerivations().entrySet().iterator().next(); + Assertions.assertArrayEquals(spendPubKey.getPubKey(), entry.getKey().getPubKey()); + Assertions.assertEquals("deadbeef", entry.getValue().getMasterFingerprint()); + Assertions.assertEquals("m/352'/0'/0'/0'/0", entry.getValue().getDerivationPath()); + Assertions.assertArrayEquals(tweak, reparsed.getPsbtInputs().get(0).getSilentPaymentsTweak()); + } + + @Test + public void testSignSilentPayments() throws PSBTParseException { + ECKey spendPrivKey = ECKey.fromPrivate(Utils.hexToBytes("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")); + ECKey tweakKey = ECKey.fromPrivate(Utils.hexToBytes("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")); + byte[] tweak = Utils.bigIntegerToBytes(tweakKey.getPrivKey(), 32); + + // Compute the expected output key: (b_spend + tweak) * G + ECKey tweakedKey = spendPrivKey.addPrivate(tweakKey); + if(tweakedKey.hasOddYCoord()) { + tweakedKey = tweakedKey.negatePrivate(); + } + byte[] outputXCoord = tweakedKey.getPubKeyXCoord(); + + // Build a transaction spending a P2TR output with that key + Transaction prevTx = new Transaction(); + prevTx.setVersion(2); + prevTx.addInput(Sha256Hash.ZERO_HASH, 0, new Script(new byte[0])); + prevTx.addOutput(100000L, ScriptType.P2TR.getOutputScript(outputXCoord)); + + Transaction spendTx = new Transaction(); + spendTx.setVersion(2); + spendTx.addInput(prevTx.getTxId(), 0, new Script(new byte[0])); + spendTx.addOutput(90000L, ScriptType.P2TR.getOutputScript(outputXCoord)); + + PSBT psbt = new PSBT(spendTx); + psbt.convertVersion(2); + psbt.getPsbtInputs().getFirst().setWitnessUtxo(prevTx.getOutputs().getFirst()); + psbt.getPsbtInputs().getFirst().setSilentPaymentsTweak(tweak); + + Assertions.assertFalse(psbt.getPsbtInputs().getFirst().isSigned()); + boolean signed = psbt.getPsbtInputs().getFirst().signSilentPayments(spendPrivKey); + Assertions.assertTrue(signed); + Assertions.assertTrue(psbt.getPsbtInputs().getFirst().isSigned()); + Assertions.assertNotNull(psbt.getPsbtInputs().getFirst().getTapKeyPathSignature()); + Assertions.assertDoesNotThrow(() -> psbt.getPsbtInputs().getFirst().verifySignatures()); + } + + @Test + public void testSignSilentPaymentsTweakMismatch() throws PSBTParseException { + ECKey spendPrivKey = ECKey.fromPrivate(Utils.hexToBytes("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")); + ECKey correctTweakKey = ECKey.fromPrivate(Utils.hexToBytes("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")); + ECKey wrongTweakKey = ECKey.fromPrivate(Utils.hexToBytes("d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5")); + + // Build output using the correct tweak + ECKey correctTweakedKey = spendPrivKey.addPrivate(correctTweakKey); + if(correctTweakedKey.hasOddYCoord()) { + correctTweakedKey = correctTweakedKey.negatePrivate(); + } + byte[] outputXCoord = correctTweakedKey.getPubKeyXCoord(); + + Transaction prevTx = new Transaction(); + prevTx.setVersion(2); + prevTx.addInput(Sha256Hash.ZERO_HASH, 0, new Script(new byte[0])); + prevTx.addOutput(100000L, ScriptType.P2TR.getOutputScript(outputXCoord)); + + Transaction spendTx = new Transaction(); + spendTx.setVersion(2); + spendTx.addInput(prevTx.getTxId(), 0, new Script(new byte[0])); + spendTx.addOutput(90000L, ScriptType.P2TR.getOutputScript(outputXCoord)); + + PSBT psbt = new PSBT(spendTx); + psbt.convertVersion(2); + psbt.getPsbtInputs().getFirst().setWitnessUtxo(prevTx.getOutputs().getFirst()); + // Set the wrong tweak — x-coordinate won't match + psbt.getPsbtInputs().getFirst().setSilentPaymentsTweak(Utils.bigIntegerToBytes(wrongTweakKey.getPrivKey(), 32)); + + Assertions.assertThrows(IllegalStateException.class, () -> psbt.getPsbtInputs().getFirst().signSilentPayments(spendPrivKey)); + } + + @Test + public void testClearNonFinalFieldsRemovesSpFields() throws PSBTParseException { + Transaction transaction = new Transaction(); + transaction.setVersion(2); + transaction.addInput(Sha256Hash.wrap("75ddabb27b8845f5247975c8a5ba7c6f336c4570708ebe230caf6db5217ae858"), 0, new Script(new byte[0])); + transaction.addOutput(100000L, new Script(Utils.hexToBytes("0014d85c2b71d0060b09c9886aeb815e50991dda124d"))); + + PSBT psbt = new PSBT(transaction); + PSBTInput input = psbt.getPsbtInputs().getFirst(); + + // Set all SP fields + ECKey scanKey = ECKey.fromPrivate(Utils.hexToBytes("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), true); + ECKey ecdhShare = ECKey.fromPrivate(Utils.hexToBytes("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"), true); + input.getSilentPaymentsEcdhShares().put(ECKey.fromPublicOnly(scanKey), ECKey.fromPublicOnly(ecdhShare)); + input.getSilentPaymentsSpendDerivations().put(ECKey.fromPublicOnly(scanKey), new KeyDerivation("deadbeef", "m/352'/0'/0'/0'/0")); + input.setSilentPaymentsTweak(Utils.hexToBytes("aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd")); + + Assertions.assertFalse(input.getSilentPaymentsEcdhShares().isEmpty()); + Assertions.assertFalse(input.getSilentPaymentsSpendDerivations().isEmpty()); + Assertions.assertNotNull(input.getSilentPaymentsTweak()); + + input.clearNonFinalFields(); + + Assertions.assertTrue(input.getSilentPaymentsEcdhShares().isEmpty()); + Assertions.assertTrue(input.getSilentPaymentsDLEQProofs().isEmpty()); + Assertions.assertTrue(input.getSilentPaymentsSpendDerivations().isEmpty()); + Assertions.assertNull(input.getSilentPaymentsTweak()); + } + @AfterEach public void tearDown() { Network.set(null);