support spending silent payments outputs as per bip376
This commit is contained in:
parent
71604b9489
commit
3c12447a63
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user