support spending silent payments outputs as per bip376

This commit is contained in:
Craig Raw 2026-04-20 09:53:47 +02:00
parent 71604b9489
commit 3c12447a63
5 changed files with 238 additions and 5 deletions

View File

@ -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<PSBTEntry> 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) {

View File

@ -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<ECKey, ECKey> silentPaymentsEcdhShares = new LinkedHashMap<>();
private final Map<ECKey, SilentPaymentsDLEQProof> silentPaymentsDLEQProofs = new LinkedHashMap<>();
private final Map<ECKey, KeyDerivation> 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<ECKey, SilentPaymentsDLEQProof> entry : silentPaymentsDLEQProofs.entrySet()) {
entries.add(populateEntry(PSBT_IN_SP_DLEQ, entry.getKey().getPubKey(), entry.getValue().getBytes()));
}
for(Map.Entry<ECKey, KeyDerivation> 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<String, String> 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<ECKey, KeyDerivation> 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) {

View File

@ -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<ECKey, KeyDerivation> spendDerivations) throws MnemonicException {
String masterFingerprint = getKeyDerivation().getMasterFingerprint();
for(Map.Entry<ECKey, KeyDerivation> 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<ChildNumber> 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();

View File

@ -1729,11 +1729,16 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
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);
}
}
}
}

View File

@ -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<ECKey, KeyDerivation> 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);