implement bip375 with tests

This commit is contained in:
Craig Raw 2025-11-18 10:35:58 +02:00
parent 8e134305c5
commit 6bd3d80303
10 changed files with 595 additions and 32 deletions

View File

@ -8,6 +8,8 @@ import java.util.List;
import java.util.Locale;
public class KeyDerivation {
public static final String DEFAULT_WATCH_ONLY_FINGERPRINT = "00000000";
private final String masterFingerprint;
private final String derivationPath;
private transient List<ChildNumber> derivation;

View File

@ -5,14 +5,18 @@ import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.silentpayments.InvalidSilentPaymentException;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentUtils;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentsDLEQProof;
import com.sparrowwallet.drongo.wallet.*;
import org.bouncycastle.util.encoders.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.psbt.PSBTEntry.*;
import static com.sparrowwallet.drongo.psbt.PSBTInput.*;
@ -27,6 +31,8 @@ public class PSBT {
public static final byte PSBT_GLOBAL_INPUT_COUNT = 0x04;
public static final byte PSBT_GLOBAL_OUTPUT_COUNT = 0x05;
public static final byte PSBT_GLOBAL_TX_MODIFIABLE = 0x06;
public static final byte PSBT_GLOBAL_SP_ECDH_SHARE = 0x07;
public static final byte PSBT_GLOBAL_SP_DLEQ = 0x08;
public static final byte PSBT_GLOBAL_VERSION = (byte)0xfb;
public static final byte PSBT_GLOBAL_PROPRIETARY = (byte)0xfc;
@ -43,17 +49,21 @@ public class PSBT {
private byte[] psbtBytes;
private Transaction transaction = null;
private Integer version = null;
private final Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
private final Map<String, String> globalProprietary = new LinkedHashMap<>();
//PSBTv2 fields
//PSBTv0-only fields
private Transaction transaction = null;
//PSBTv2-only fields
private Long txVersion = null;
private Long fallbackLocktime = null;
private Long inputCount = null;
private Long outputCount = null;
private Byte modifiable = null;
private final Map<ECKey, ECKey> silentPaymentsEcdhShares = new LinkedHashMap<>();
private final Map<ECKey, SilentPaymentsDLEQProof> silentPaymentsDLEQProofs = new LinkedHashMap<>();
private final List<PSBTInput> psbtInputs = new ArrayList<>();
private final List<PSBTOutput> psbtOutputs = new ArrayList<>();
@ -326,12 +336,18 @@ public class PSBT {
break;
case PSBT_GLOBAL_TX_VERSION:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT global tx version must be 4 bytes");
}
long txVersion = Utils.readUint32(entry.getData(), 0);
this.txVersion = txVersion;
log.debug("PSBT tx version: " + txVersion);
break;
case PSBT_GLOBAL_FALLBACK_LOCKTIME:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT global fallback locktime must be 4 bytes");
}
long fallbackLocktime = Utils.readUint32(entry.getData(), 0);
this.fallbackLocktime = fallbackLocktime;
log.debug("PSBT fallback locktime: " + fallbackLocktime);
@ -360,10 +376,33 @@ public class PSBT {
break;
case PSBT_GLOBAL_VERSION:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT global version must be 4 bytes");
}
int version = (int)Utils.readUint32(entry.getData(), 0);
this.version = version;
log.debug("PSBT version: " + version);
break;
case PSBT_GLOBAL_SP_ECDH_SHARE:
entry.checkOneBytePlusPubKey();
if(entry.getData().length != 33) {
throw new PSBTParseException("PSBT global silent payments ECDH share data must be 33 bytes");
}
ECKey scanKey = ECKey.fromPublicOnly(entry.getKeyData());
ECKey ecdhShare = ECKey.fromPublicOnly(entry.getData());
this.silentPaymentsEcdhShares.put(scanKey, ecdhShare);
log.debug("PSBT global silent payments ECDH share for scan key: " + Utils.bytesToHex(entry.getKeyData()));
break;
case PSBT_GLOBAL_SP_DLEQ:
entry.checkOneBytePlusPubKey();
if(entry.getData().length != 64) {
throw new PSBTParseException("PSBT global silent payments DLEQ proof data must be 64 bytes");
}
ECKey proofScanKey = ECKey.fromPublicOnly(entry.getKeyData());
SilentPaymentsDLEQProof dleqProof = SilentPaymentsDLEQProof.fromBytes(entry.getData());
this.silentPaymentsDLEQProofs.put(proofScanKey, dleqProof);
log.debug("PSBT global silent payments DLEQ proof for scan key: " + Utils.bytesToHex(entry.getKeyData()));
break;
case PSBT_GLOBAL_PROPRIETARY:
globalProprietary.put(Utils.bytesToHex(entry.getKeyData()), Utils.bytesToHex(entry.getData()));
log.debug("PSBT global proprietary data: " + Utils.bytesToHex(entry.getData()));
@ -392,6 +431,12 @@ public class PSBT {
if(modifiable != null) {
throw new PSBTParseException("PSBT_GLOBAL_TX_MODIFIABLE is not allowed in PSBTv0");
}
if(!silentPaymentsEcdhShares.isEmpty()) {
throw new PSBTParseException("PSBT_GLOBAL_SP_ECDH_SHARE is not allowed in PSBTv0");
}
if(!silentPaymentsDLEQProofs.isEmpty()) {
throw new PSBTParseException("PSBT_GLOBAL_SP_DLEQ is not allowed in PSBTv0");
}
} else if(getPsbtVersion() == 1) {
throw new PSBTParseException("There is no PSBTv1");
} else if(getPsbtVersion() >= 2) {
@ -436,6 +481,12 @@ public class PSBT {
if(input.getRequiredHeightLocktime() != null) {
throw new PSBTParseException("PSBT_IN_REQUIRED_HEIGHT_LOCKTIME is not allowed in PSBTv0");
}
if(!input.getSilentPaymentsEcdhShares().isEmpty()) {
throw new PSBTParseException("PSBT_IN_SP_ECDH_SHARE is not allowed in PSBTv0");
}
if(!input.getSilentPaymentsDLEQProofs().isEmpty()) {
throw new PSBTParseException("PSBT_IN_SP_DLEQ is not allowed in PSBTv0");
}
} else if(getPsbtVersion() >= 2) {
if(input.prevTxid() == null) {
throw new PSBTParseException("PSBT_IN_PREV_TXID is required in PSBTv2");
@ -466,6 +517,12 @@ public class PSBT {
if(output.script() != null) {
throw new PSBTParseException("PSBT_OUT_SCRIPT is not allowed in PSBTv0");
}
if(output.getSilentPaymentAddress() != null) {
throw new PSBTParseException("PSBT_OUT_SP_V0_INFO is not allowed in PSBTv0");
}
if(output.getSilentPaymentLabel() != null) {
throw new PSBTParseException("PSBT_OUT_SP_V0_LABEL is not allowed in PSBTv0");
}
} else if(getPsbtVersion() >= 2) {
if(output.amount() == null) {
throw new PSBTParseException("PSBT_OUT_AMOUNT is required in PSBTv2");
@ -531,6 +588,99 @@ public class PSBT {
}
}
/**
* Validates silent payment ECDH shares and DLEQ proofs according to BIP-375.
*
* For each silent payment output, validates that:
* 1. Either global or per-input ECDH shares and DLEQ proofs are provided for Taproot inputs
* 2. The DLEQ proofs are cryptographically valid
* 3. The output scripts match the expected scripts computed from the ECDH shares
*
* @param inputPublicKeys Map of PSBTInput to their public keys for verification
* @throws PSBTProofException if validation fails
*/
public void validateSilentPayments(Map<TransactionInput, ECKey> inputPublicKeys) throws PSBTProofException {
Set<ECKey> scanKeys = getPsbtOutputs().stream().filter(output -> output.getSilentPaymentAddress() != null)
.map(output -> output.getSilentPaymentAddress().getScanKey()).collect(Collectors.toCollection(LinkedHashSet::new));
if(scanKeys.isEmpty()) {
return;
}
for(PSBTInput input : getPsbtInputs()) {
SigHash sigHash = input.getSigHash();
if(sigHash != null && sigHash != SigHash.ALL && sigHash != SigHash.DEFAULT) {
throw new PSBTProofException("Silent payment outputs require SIGHASH_ALL signatures only. Input at index " + input.getIndex() + " has sighash type: " + sigHash);
}
}
Set<HashIndex> outpoints = inputPublicKeys.keySet().stream()
.map(input -> new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex()))
.collect(Collectors.toCollection(LinkedHashSet::new));
ECKey summedPublicKey = SilentPaymentUtils.getSummedPublicKey(inputPublicKeys.values());
if(summedPublicKey == null) {
throw new PSBTProofException("Cannot verify global DLEQ proof without input public keys");
}
for(ECKey scanKey : scanKeys) {
List<SilentPayment> silentPayments = getPsbtOutputs().stream()
.filter(output -> output.getSilentPaymentAddress() != null && output.getSilentPaymentAddress().getScanKey().equals(scanKey) && output.getScript() != null)
.map(psbtOutput -> new SilentPayment(psbtOutput.getSilentPaymentAddress(), psbtOutput.getScript().getToAddress(), null, psbtOutput.getAmount(), false))
.collect(Collectors.toList());
if(silentPayments.isEmpty()) {
continue;
}
boolean hasGlobalEcdhShare = silentPaymentsEcdhShares.containsKey(scanKey);
boolean hasGlobalDleqProof = silentPaymentsDLEQProofs.containsKey(scanKey);
if(hasGlobalEcdhShare && hasGlobalDleqProof) {
ECKey globalEcdhShare = silentPaymentsEcdhShares.get(scanKey);
SilentPaymentsDLEQProof globalProof = silentPaymentsDLEQProofs.get(scanKey);
if(!globalProof.verify(summedPublicKey, scanKey, globalEcdhShare)) {
throw new PSBTProofException("Global DLEQ proof verification failed for scan key: " + scanKey);
}
try {
SilentPaymentUtils.validateOutputAddresses(silentPayments, globalEcdhShare, summedPublicKey, outpoints);
} catch(InvalidSilentPaymentException e) {
throw new PSBTProofException(e);
}
} else if(hasGlobalEcdhShare || hasGlobalDleqProof) {
throw new PSBTProofException("Global ECDH share and DLEQ proof must both be present for scan key: " + scanKey);
} else {
List<ECKey> inputEcdhShares = new ArrayList<>();
for(PSBTInput input : getPsbtInputs()) {
ECKey inputPublicKey = inputPublicKeys.entrySet().stream()
.filter(entry -> entry.getKey().getIndex() == input.getIndex()).map(Map.Entry::getValue).findFirst().orElse(null);
if(inputPublicKey != null) {
boolean hasInputEcdhShare = input.getSilentPaymentsEcdhShares().containsKey(scanKey);
boolean hasInputDleqProof = input.getSilentPaymentsDLEQProofs().containsKey(scanKey);
if(!hasInputEcdhShare || !hasInputDleqProof) {
throw new PSBTProofException("Eligible input at index " + input.getIndex() + " must provide PSBT_IN_SP_ECDH_SHARE and PSBT_IN_SP_DLEQ");
}
ECKey inputEcdhShare = input.getSilentPaymentsEcdhShares().get(scanKey);
SilentPaymentsDLEQProof inputProof = input.getSilentPaymentsDLEQProofs().get(scanKey);
if(!inputProof.verify(inputPublicKey, scanKey, inputEcdhShare)) {
throw new PSBTProofException("Input DLEQ proof verification for input at index " + input.getIndex() + " failed for scan key: " + scanKey);
}
inputEcdhShares.add(inputEcdhShare);
}
}
ECKey summedEcdhShare = SilentPaymentUtils.getSummedPublicKey(inputEcdhShares);
if(summedEcdhShare == null) {
throw new PSBTProofException("No ECDH shares found for scan key: " + scanKey);
}
try {
SilentPaymentUtils.validateOutputAddresses(silentPayments, summedEcdhShare, summedPublicKey, outpoints);
} catch(InvalidSilentPaymentException e) {
throw new PSBTProofException(e);
}
}
}
}
public boolean hasSignatures() {
for(PSBTInput psbtInput : getPsbtInputs()) {
if(!psbtInput.getPartialSignatures().isEmpty() || psbtInput.getTapKeyPathSignature() != null || psbtInput.getFinalScriptSig() != null || psbtInput.getFinalScriptWitness() != null) {
@ -594,6 +744,12 @@ public class PSBT {
if(modifiable != null) {
entries.add(populateEntry(PSBT_GLOBAL_TX_MODIFIABLE, null, new byte[] { modifiable }));
}
for(Map.Entry<ECKey, ECKey> entry : silentPaymentsEcdhShares.entrySet()) {
entries.add(populateEntry(PSBT_GLOBAL_SP_ECDH_SHARE, entry.getKey().getPubKey(), entry.getValue().getPubKey()));
}
for(Map.Entry<ECKey, SilentPaymentsDLEQProof> entry : silentPaymentsDLEQProofs.entrySet()) {
entries.add(populateEntry(PSBT_GLOBAL_SP_DLEQ, entry.getKey().getPubKey(), entry.getValue().getBytes()));
}
}
if(version != null) {
@ -681,6 +837,8 @@ public class PSBT {
}
extendedPublicKeys.putAll(psbt.extendedPublicKeys);
silentPaymentsEcdhShares.putAll(psbt.silentPaymentsEcdhShares);
silentPaymentsDLEQProofs.putAll(psbt.silentPaymentsDLEQProofs);
globalProprietary.putAll(psbt.globalProprietary);
for(int i = 0; i < getPsbtInputs().size(); i++) {
@ -724,6 +882,17 @@ public class PSBT {
}
}
for(int i = 0; i < finalTransaction.getOutputs().size(); i++) {
TransactionOutput txOutput = finalTransaction.getOutputs().get(i);
PSBTOutput psbtOutput = getPsbtOutputs().get(i);
if(psbtOutput.getSilentPaymentAddress() != null) {
if(psbtOutput.script() == null || !ScriptType.P2TR.isScriptType(psbtOutput.script())) {
throw new IllegalStateException("Silent payment output at index " + i + " must provide a valid P2TR PSBT_OUT_SCRIPT");
}
txOutput.setScriptBytes(psbtOutput.script().getProgram());
}
}
return finalTransaction;
}
@ -803,7 +972,12 @@ public class PSBT {
}
}
for(PSBTOutput psbtOutput : getPsbtOutputs()) {
transaction.addOutput(psbtOutput.getAmount(), psbtOutput.getScript() == null ? new Script(new byte[0]) : psbtOutput.getScript());
//For unsigned transactions set the output script to the serialized silent payments address if present as per BIP375
if(psbtOutput.getSilentPaymentAddress() != null) {
transaction.addOutput(psbtOutput.getAmount(), new Script(psbtOutput.getSilentPaymentAddress().serialize()));
} else {
transaction.addOutput(psbtOutput.getAmount(), psbtOutput.getScript() == null ? new Script(new byte[0]) : psbtOutput.getScript());
}
}
return transaction;
}
@ -889,6 +1063,14 @@ public class PSBT {
}
}
public Map<ECKey, ECKey> getSilentPaymentsEcdhShares() {
return silentPaymentsEcdhShares;
}
public Map<ECKey, SilentPaymentsDLEQProof> getSilentPaymentsDLEQProofs() {
return silentPaymentsDLEQProofs;
}
public Map<String, String> getGlobalProprietary() {
return globalProprietary;
}
@ -902,7 +1084,7 @@ public class PSBT {
}
public String toBase64String(boolean includeXpubs) {
return Base64.toBase64String(serialize(includeXpubs, true));
return Base64.getEncoder().encodeToString(serialize(includeXpubs, true));
}
public void convertVersion(int version) {
@ -1028,7 +1210,7 @@ public class PSBT {
if(Utils.isHex(s) && s.startsWith(PSBT_MAGIC_HEX)) {
return true;
} else {
return Utils.isBase64(s) && Utils.bytesToHex(Base64.decode(s)).startsWith(PSBT_MAGIC_HEX);
return Utils.isBase64(s) && Utils.bytesToHex(Base64.getDecoder().decode(s)).startsWith(PSBT_MAGIC_HEX);
}
} catch(Exception e) {
//ignore
@ -1047,7 +1229,7 @@ public class PSBT {
}
if (Utils.isBase64(strPSBT) && !Utils.isHex(strPSBT)) {
strPSBT = Utils.bytesToHex(Base64.decode(strPSBT));
strPSBT = Utils.bytesToHex(Base64.getDecoder().decode(strPSBT));
}
byte[] psbtBytes = Utils.hexToBytes(strPSBT);

View File

@ -10,10 +10,7 @@ import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.*;
public class PSBTEntry {
private final byte[] key;
@ -80,6 +77,9 @@ public class PSBTEntry {
}
public static KeyDerivation parseKeyDerivation(byte[] data) throws PSBTParseException {
if(data.length == 0) {
return new KeyDerivation(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT, Collections.emptyList());
}
if(data.length < 4) {
throw new PSBTParseException("Invalid master fingerprint specified: not enough bytes");
}

View File

@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentsDLEQProof;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -35,10 +36,12 @@ public class PSBTInput {
public static final byte PSBT_IN_SEQUENCE = 0x10;
public static final byte PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11;
public static final byte PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12;
public static final byte PSBT_IN_PROPRIETARY = (byte)0xfc;
public static final byte PSBT_IN_TAP_KEY_SIG = 0x13;
public static final byte PSBT_IN_TAP_BIP32_DERIVATION = 0x16;
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_PROPRIETARY = (byte)0xfc;
private final PSBT psbt;
private Transaction nonWitnessUtxo;
@ -60,12 +63,14 @@ public class PSBTInput {
private Map<ECKey, Map<KeyDerivation, List<Sha256Hash>>> tapDerivedPublicKeys = new LinkedHashMap<>();
private ECKey tapInternalKey;
//PSBTv2 fields
//PSBTv2-only fields
private Sha256Hash prevTxid;
private Long prevIndex;
private Long sequence;
private Long requiredTimeLocktime;
private Long requiredHeightLocktime;
private final Map<ECKey, ECKey> silentPaymentsEcdhShares = new LinkedHashMap<>();
private final Map<ECKey, SilentPaymentsDLEQProof> silentPaymentsDLEQProofs = new LinkedHashMap<>();
private int index;
@ -168,6 +173,9 @@ public class PSBTInput {
break;
case PSBT_IN_SIGHASH_TYPE:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT input sighash type must be 4 bytes");
}
long sighashType = Utils.readUint32(entry.getData(), 0);
SigHash sigHash = SigHash.fromByte((byte)sighashType);
this.sigHash = sigHash;
@ -284,16 +292,25 @@ public class PSBTInput {
break;
case PSBT_IN_OUTPUT_INDEX:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT input output index must be 4 bytes");
}
this.prevIndex = Utils.readUint32(entry.getData(), 0);
log.debug("Found input previous output index " + this.prevIndex);
break;
case PSBT_IN_SEQUENCE:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT input sequence must be 4 bytes");
}
this.sequence = Utils.readUint32(entry.getData(), 0);
log.debug("Found input sequence " + this.sequence);
break;
case PSBT_IN_REQUIRED_TIME_LOCKTIME:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT input required time locktime must be 4 bytes");
}
long requiredTimeLocktime = Utils.readUint32(entry.getData(), 0);
if(requiredTimeLocktime < 500000000) {
throw new PSBTParseException("Required time locktime is less than 500000000");
@ -303,6 +320,9 @@ public class PSBTInput {
break;
case PSBT_IN_REQUIRED_HEIGHT_LOCKTIME:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT input required height locktime must be 4 bytes");
}
long requiredHeightLocktime = Utils.readUint32(entry.getData(), 0);
if(requiredHeightLocktime >= 500000000) {
throw new PSBTParseException("Required time locktime is greater than or equal to 500000000");
@ -310,6 +330,26 @@ public class PSBTInput {
this.requiredHeightLocktime = requiredHeightLocktime;
log.debug("Found input required height locktime " + this.requiredHeightLocktime);
break;
case PSBT_IN_SP_ECDH_SHARE:
entry.checkOneBytePlusPubKey();
if(entry.getData().length != 33) {
throw new PSBTParseException("PSBT input silent payments ECDH share data must be 33 bytes");
}
ECKey inputScanKey = ECKey.fromPublicOnly(entry.getKeyData());
ECKey inputEcdhShare = ECKey.fromPublicOnly(entry.getData());
this.silentPaymentsEcdhShares.put(inputScanKey, inputEcdhShare);
log.debug("Found input silent payments ECDH share for scan key: " + Utils.bytesToHex(entry.getKeyData()));
break;
case PSBT_IN_SP_DLEQ:
entry.checkOneBytePlusPubKey();
if(entry.getData().length != 64) {
throw new PSBTParseException("PSBT input silent payments DLEQ proof data must be 64 bytes");
}
ECKey inputProofScanKey = ECKey.fromPublicOnly(entry.getKeyData());
SilentPaymentsDLEQProof inputDleqProof = SilentPaymentsDLEQProof.fromBytes(entry.getData());
this.silentPaymentsDLEQProofs.put(inputProofScanKey, inputDleqProof);
log.debug("Found input silent payments DLEQ proof for scan key: " + Utils.bytesToHex(entry.getKeyData()));
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()));
@ -413,6 +453,12 @@ public class PSBTInput {
Utils.uint32ToByteArrayLE(requiredHeightLocktime, requiredHeightLocktimeBytes, 0);
entries.add(populateEntry(PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, null, requiredHeightLocktimeBytes));
}
for(Map.Entry<ECKey, ECKey> entry : silentPaymentsEcdhShares.entrySet()) {
entries.add(populateEntry(PSBT_IN_SP_ECDH_SHARE, entry.getKey().getPubKey(), entry.getValue().getPubKey()));
}
for(Map.Entry<ECKey, SilentPaymentsDLEQProof> entry : silentPaymentsDLEQProofs.entrySet()) {
entries.add(populateEntry(PSBT_IN_SP_DLEQ, entry.getKey().getPubKey(), entry.getValue().getBytes()));
}
}
for(Map.Entry<String, String> entry : proprietary.entrySet()) {
@ -501,6 +547,9 @@ public class PSBTInput {
requiredHeightLocktime = psbtInput.requiredHeightLocktime;
}
silentPaymentsEcdhShares.putAll(psbtInput.silentPaymentsEcdhShares);
silentPaymentsDLEQProofs.putAll(psbtInput.silentPaymentsDLEQProofs);
proprietary.putAll(psbtInput.proprietary);
if(psbtInput.tapKeyPathSignature != null) {
@ -732,6 +781,14 @@ public class PSBTInput {
this.requiredHeightLocktime = requiredHeightLocktime;
}
public Map<ECKey, ECKey> getSilentPaymentsEcdhShares() {
return silentPaymentsEcdhShares;
}
public Map<ECKey, SilentPaymentsDLEQProof> getSilentPaymentsDLEQProofs() {
return silentPaymentsDLEQProofs;
}
public boolean isSigned() {
if(getTapKeyPathSignature() != null) {
return true;
@ -937,6 +994,10 @@ public class PSBTInput {
return getWitnessUtxo() != null ? getWitnessUtxo() : (getNonWitnessUtxo() != null ? getNonWitnessUtxo().getOutputs().get(vout) : null);
}
int getIndex() {
return index;
}
void setIndex(int index) {
this.index = index;
}

View File

@ -28,6 +28,7 @@ public class PSBTOutput {
public static final byte PSBT_OUT_TAP_INTERNAL_KEY = 0x05;
public static final byte PSBT_OUT_TAP_BIP32_DERIVATION = 0x07;
public static final byte PSBT_OUT_SP_V0_INFO = 0x09;
public static final byte PSBT_OUT_SP_V0_LABEL = 0x0a;
public static final byte PSBT_OUT_DNSSEC_PROOF = 0x35;
public static final byte PSBT_OUT_PROPRIETARY = (byte)0xfc;
@ -39,10 +40,11 @@ public class PSBTOutput {
private ECKey tapInternalKey;
private Map<String, byte[]> dnssecProof;
//PSBTv2 fields
//PSBTv2-only fields
private Long amount;
private Script script;
private SilentPaymentAddress silentPaymentAddress;
private Long silentPaymentLabel;
private static final Logger log = LoggerFactory.getLogger(PSBTOutput.class);
@ -103,6 +105,9 @@ public class PSBTOutput {
break;
case PSBT_OUT_AMOUNT:
entry.checkOneByteKey();
if(entry.getData().length != 8) {
throw new PSBTParseException("PSBT output amount must be 8 bytes");
}
this.amount = Utils.readInt64(entry.getData(), 0);
log.debug("Found output amount " + this.amount);
break;
@ -144,6 +149,16 @@ public class PSBTOutput {
byte[] spendKey = new byte[33];
System.arraycopy(entry.getData(), 33, spendKey, 0, 33);
this.silentPaymentAddress = new SilentPaymentAddress(ECKey.fromPublicOnly(scanKey), ECKey.fromPublicOnly(spendKey));
log.debug("Found output silent payment address " + this.silentPaymentAddress);
break;
case PSBT_OUT_SP_V0_LABEL:
entry.checkOneByteKey();
if(entry.getData().length != 4) {
throw new PSBTParseException("PSBT output silent payment label must be 4 bytes");
}
this.silentPaymentLabel = Utils.readUint32(entry.getData(), 0);
log.debug("Found output silent payment label " + this.silentPaymentLabel);
break;
case PSBT_OUT_DNSSEC_PROOF:
entry.checkOneByteKey();
this.dnssecProof = parseDnssecProof(entry.getData());
@ -181,6 +196,11 @@ public class PSBTOutput {
if(silentPaymentAddress != null) {
entries.add(populateEntry(PSBT_OUT_SP_V0_INFO, null, Utils.concat(silentPaymentAddress.getScanKey().getPubKey(), silentPaymentAddress.getSpendKey().getPubKey())));
}
if(silentPaymentLabel != null) {
byte[] labelBytes = new byte[4];
Utils.uint32ToByteArrayLE(silentPaymentLabel, labelBytes, 0);
entries.add(populateEntry(PSBT_OUT_SP_V0_LABEL, null, labelBytes));
}
}
for(Map.Entry<String, String> entry : proprietary.entrySet()) {
@ -223,13 +243,21 @@ public class PSBTOutput {
script = psbtOutput.script;
}
proprietary.putAll(psbtOutput.proprietary);
tapDerivedPublicKeys.putAll(psbtOutput.tapDerivedPublicKeys);
if(psbtOutput.tapInternalKey != null) {
tapInternalKey = psbtOutput.tapInternalKey;
}
if(psbtOutput.silentPaymentAddress != null) {
silentPaymentAddress = psbtOutput.silentPaymentAddress;
}
if(psbtOutput.silentPaymentLabel != null) {
silentPaymentLabel = psbtOutput.silentPaymentLabel;
}
proprietary.putAll(psbtOutput.proprietary);
}
public Script getRedeemScript() {
@ -316,6 +344,14 @@ public class PSBTOutput {
this.silentPaymentAddress = silentPaymentAddress;
}
public Long getSilentPaymentLabel() {
return silentPaymentLabel;
}
public void setSilentPaymentLabel(Long silentPaymentLabel) {
this.silentPaymentLabel = silentPaymentLabel;
}
public Map<String, byte[]> getDnssecProof() {
return dnssecProof;
}

View File

@ -0,0 +1,19 @@
package com.sparrowwallet.drongo.psbt;
public class PSBTProofException extends PSBTParseException {
public PSBTProofException() {
super();
}
public PSBTProofException(String message) {
super(message);
}
public PSBTProofException(Throwable cause) {
super(cause);
}
public PSBTProofException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -5,9 +5,12 @@ import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.Bech32;
import java.nio.ByteBuffer;
import java.util.Arrays;
public class SilentPaymentAddress {
public static final int VERSION = 0;
private final ECKey scanAddress;
private final ECKey spendAddress;
@ -26,7 +29,7 @@ public class SilentPaymentAddress {
public String getAddress() {
byte[] keys = Utils.concat(scanAddress.getPubKey(), spendAddress.getPubKey());
return Bech32.encode(Network.get().getSilentPaymentsAddressHrp(), 0, Bech32.Encoding.BECH32M, keys);
return Bech32.encode(Network.get().getSilentPaymentsAddressHrp(), VERSION, Bech32.Encoding.BECH32M, keys);
}
public static SilentPaymentAddress from(String address) {
@ -40,7 +43,7 @@ public class SilentPaymentAddress {
}
int witnessVersion = data.data[0];
if(witnessVersion != 0) {
if(witnessVersion != VERSION) {
throw new UnsupportedOperationException("Unsupported silent payments address witness version");
}
@ -83,4 +86,12 @@ public class SilentPaymentAddress {
public int hashCode() {
return getAddress().hashCode();
}
public byte[] serialize() {
ByteBuffer buffer = ByteBuffer.allocate(67);
buffer.put((byte)VERSION);
buffer.put(scanAddress.getPubKey());
buffer.put(spendAddress.getPubKey());
return buffer.array();
}
}

View File

@ -1,6 +1,7 @@
package com.sparrowwallet.drongo.silentpayments;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.MnemonicException;
@ -50,8 +51,8 @@ public class SilentPaymentUtils {
return true;
}
public static List<ECKey> getInputPubKeys(Transaction tx, Map<HashIndex, Script> spentScriptPubKeys) {
List<ECKey> keys = new ArrayList<>();
public static Map<TransactionInput, ECKey> getInputPubKeys(Transaction tx, Map<HashIndex, Script> spentScriptPubKeys) {
Map<TransactionInput, ECKey> inputKeys = new LinkedHashMap<>();
for(TransactionInput input : tx.getInputs()) {
HashIndex hashIndex = new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex());
Script scriptPubKey = spentScriptPubKeys.get(hashIndex);
@ -82,7 +83,7 @@ public class SilentPaymentUtils {
ECKey pubKey = ScriptType.P2TR.getPublicKeyFromScript(scriptPubKey);
if(pubKey.isCompressed()) {
keys.add(pubKey);
inputKeys.put(input, pubKey);
}
}
break;
@ -92,7 +93,7 @@ public class SilentPaymentUtils {
if(input.getWitness() != null && input.getWitness().getPushCount() == 2) {
byte[] pubKey = input.getWitness().getPushes().getLast();
if(pubKey != null && pubKey.length == 33) {
keys.add(ECKey.fromPublicOnly(pubKey));
inputKeys.put(input, ECKey.fromPublicOnly(pubKey));
}
}
}
@ -101,7 +102,7 @@ public class SilentPaymentUtils {
if(input.getWitness() != null && input.getWitness().getPushCount() == 2) {
byte[] pubKey = input.getWitness().getPushes().getLast();
if(pubKey != null && pubKey.length == 33) {
keys.add(ECKey.fromPublicOnly(pubKey));
inputKeys.put(input, ECKey.fromPublicOnly(pubKey));
}
}
break;
@ -109,7 +110,7 @@ public class SilentPaymentUtils {
byte[] spkHash = ScriptType.P2PKH.getHashFromScript(scriptPubKey);
for(ScriptChunk scriptChunk : input.getScriptSig().getChunks()) {
if(scriptChunk.isPubKey() && scriptChunk.getData().length == 33 && Arrays.equals(Utils.sha256hash160(scriptChunk.getData()), spkHash)) {
keys.add(scriptChunk.getPubKey());
inputKeys.put(input, scriptChunk.getPubKey());
break;
}
}
@ -121,7 +122,7 @@ public class SilentPaymentUtils {
}
}
return keys;
return inputKeys;
}
public static boolean containsTaprootOutput(Transaction tx) {
@ -164,7 +165,7 @@ public class SilentPaymentUtils {
return null;
}
List<ECKey> inputKeys = getInputPubKeys(tx, spentScriptPubKeys);
Map<TransactionInput, ECKey> inputKeys = getInputPubKeys(tx, spentScriptPubKeys);
if(inputKeys.isEmpty()) {
return null;
}
@ -175,8 +176,9 @@ public class SilentPaymentUtils {
try {
byte[][] inputPubKeys = new byte[inputKeys.size()][];
for(int i = 0; i < inputPubKeys.length; i++) {
inputPubKeys[i] = inputKeys.get(i).getPubKey(true);
int index = 0;
for (ECKey key : inputKeys.values()) {
inputPubKeys[index++] = key.getPubKey(true);
}
byte[] combinedPubKey = NativeSecp256k1.pubKeyCombine(inputPubKeys, true);
byte[] smallestOutpoint = tx.getInputs().stream().map(input -> input.getOutpoint().bitcoinSerialize()).min(new Utils.LexicographicByteArrayComparator()).orElseThrow();
@ -190,6 +192,16 @@ public class SilentPaymentUtils {
return null;
}
/**
* Computes the output addresses for a list of silent payments by calculating the shared secret
* between scan keys, spend keys, and the summed private key derived from the provided UTXOs.
* Updates each silent payment instance with the corresponding address.
*
* @param silentPayments the list of silent payments containing silent payment addresses and metadata
* @param utxos a map of UTXOs (unspent transaction outputs) to wallet nodes, containing information
* about inputs used to derive the summed private key
* @throws InvalidSilentPaymentException if the computed shared secrets or addresses are invalid
*/
public static void computeOutputAddresses(List<SilentPayment> silentPayments, Map<HashIndex, WalletNode> utxos) throws InvalidSilentPaymentException {
ECKey summedPrivateKey = getSummedPrivateKey(utxos.values());
BigInteger inputHash = getInputHash(utxos.keySet(), summedPrivateKey);
@ -212,6 +224,44 @@ public class SilentPaymentUtils {
}
}
/**
* Validates that the output scripts for silent payment outputs match the expected scripts
* computed from the ECDH shares. This implements BIP-375 output script verification.
*
* @param silentPayments List of silent payments sending to a common scan key
* @param ecdhShare The ECDH share (a * B_scan), either global or summed from per-input
* @param summedPublicKey The sum of all eligible input public keys
* @param outpoints Set of outpoints for eligible inputs
* @throws InvalidSilentPaymentException if validation fails or scripts don't match
*/
public static void validateOutputAddresses(List<SilentPayment> silentPayments, ECKey ecdhShare, ECKey summedPublicKey, Set<HashIndex> outpoints) throws InvalidSilentPaymentException {
BigInteger inputHash = SilentPaymentUtils.getInputHash(outpoints, summedPublicKey);
Map<ECKey, List<SilentPayment>> scanKeyGroups = SilentPaymentUtils.getScanKeyGroups(silentPayments);
for(Map.Entry<ECKey, List<SilentPayment>> scanKeyGroup : scanKeyGroups.entrySet()) {
// Compute shared secret from ECDH share and input hash
// Instead of: sharedSecret = scanKey.multiply(inputHash).multiply(summedPrivateKey.getPrivKey())
// We use: sharedSecret = ecdhShare.multiply(inputHash)
// Because ecdhShare is already (a * B_scan)
ECKey sharedSecret = ecdhShare.multiply(inputHash);
int k = 0;
for(SilentPayment silentPayment : scanKeyGroup.getValue()) {
BigInteger tk = new BigInteger(1, Utils.taggedHash(SilentPaymentUtils.BIP_0352_SHARED_SECRET_TAG,
Utils.concat(sharedSecret.getPubKey(true), ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(k).array())));
if(tk.equals(BigInteger.ZERO) || tk.compareTo(ECKey.CURVE.getCurve().getOrder()) >= 0) {
throw new InvalidSilentPaymentException("The tk value is invalid for the eligible silent payments inputs");
}
ECKey spendKey = silentPayment.getSilentPaymentAddress().getSpendKey();
ECKey pkm = spendKey.add(ECKey.fromPublicOnly(ECKey.publicPointFromPrivate(tk).getEncoded(true)), true);
Address expectedAddress = ScriptType.P2TR.getAddress(pkm.getPubKeyXCoord());
if(!silentPayment.getAddress().equals(expectedAddress)) {
throw new InvalidSilentPaymentException("Silent payment output address mismatch: expected " + expectedAddress + " but got " + silentPayment.getAddress());
}
k++;
}
}
}
public static Map<ECKey, List<SilentPayment>> getScanKeyGroups(Collection<SilentPayment> silentPayments) {
Map<ECKey, List<SilentPayment>> scanKeyGroups = new LinkedHashMap<>();
for(SilentPayment silentPayment : silentPayments) {
@ -223,9 +273,9 @@ public class SilentPaymentUtils {
return scanKeyGroups;
}
public static BigInteger getInputHash(Set<HashIndex> outpoints, ECKey summedPrivateKey) throws InvalidSilentPaymentException {
public static BigInteger getInputHash(Set<HashIndex> outpoints, ECKey summedInputKey) throws InvalidSilentPaymentException {
byte[] smallestOutpoint = getSmallestOutpoint(outpoints);
byte[] concat = Utils.concat(smallestOutpoint, summedPrivateKey.getPubKey());
byte[] concat = Utils.concat(smallestOutpoint, summedInputKey.getPubKey(true));
BigInteger inputHash = new BigInteger(1, Utils.taggedHash(BIP_0352_INPUTS_TAG, concat));
if(inputHash.equals(BigInteger.ZERO) || inputHash.compareTo(ECKey.CURVE.getCurve().getOrder()) >= 0) {
throw new InvalidSilentPaymentException("The input hash is invalid for the eligible silent payments inputs");
@ -267,6 +317,22 @@ public class SilentPaymentUtils {
return summedPrivateKey;
}
public static ECKey getSummedPublicKey(Collection<ECKey> publicKeys) {
ECKey summedKey = null;
for(ECKey publicKey : publicKeys) {
if(publicKey != null) {
if(summedKey == null) {
summedKey = publicKey;
} else {
summedKey = summedKey.add(publicKey, true);
}
}
}
return summedKey;
}
public static byte[] getSmallestOutpoint(Set<HashIndex> outpoints) {
return outpoints.stream().map(outpoint -> new TransactionOutPoint(outpoint.getHash(), outpoint.getIndex())).map(TransactionOutPoint::bitcoinSerialize)
.min(new Utils.LexicographicByteArrayComparator()).orElseThrow(() -> new IllegalArgumentException("No inputs provided to calculate silent payments input hash"));

View File

@ -1677,7 +1677,6 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
Script outputScript = silentPayment.getAddress().getOutputScript();
silentOutput.setScript(outputScript);
silentOutput.getOutput().setScriptBytes(outputScript.getProgram());
addSilentPaymentAddress(silentPayment.getAddress(), silentPayment.getSilentPaymentAddress());
}

View File

@ -11,6 +11,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -718,8 +719,194 @@ public class PSBTTest {
Assertions.assertEquals(origPsbtv2.getTransaction().getTxId(), psbtv0.getTransaction().getTxId());
}
// BIP-375 Silent Payments Tests
@Test
public void invalidMissingDleqProof() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCrtYht20vGCALx8ZiisSkDZZzJ7nPgIx1FVehBiNyWQAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/wEDBAEAAAAAAQMIGHMBAAAAAAABBCJRIBe7uqUS4DD7yj7lFPeyOAfhEYMaR0VEcJE0yfm/RrbEAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
Assertions.assertThrows(PSBTProofException.class, () -> parsed.validateSilentPayments(inputPublicKeys));
}
@Test
public void invalidDleqProof() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiBJpgB4JAmRsNt6srOq9JqFCEYGmw7mPo8/4lJ2+/nPoAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIeAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QIHV1pvvebv/E/qjiAN4whI/xJQnstvRxMFrJvVYN5hmLW7l6QbgreDug7YKivXVjkDvnwa9fLacXS3HUTu7/mEBAwQBAAAAAAEDCBhzAQAAAAAAAQQiUSCy5HLRM5RYz18oPOTQydTDNj9R1Kn1KSQfhJ3O1Ft10AEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
Assertions.assertThrows(PSBTProofException.class, () -> parsed.validateSilentPayments(inputPublicKeys));
}
@Test
public void invalidNonSighashAll() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCfF8iU72tyS4+7lJj0TTc8KFCARRz/QDHgOTLIuPm2twEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAIAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EB82fZvBIlT42he3KPxdpkVJZi87KRvIU7SuM39pJ22oAePiUtm39mKZwbmhzrySfXfZOzJhETpE3KXfZb62CtDAAEDCBhzAQAAAAAAAQQiUSB0TgmVHxe1gkg8egwIwGJv6sNmaTyE+vOeA0thLejccQEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
Assertions.assertThrows(PSBTProofException.class, () -> parsed.validateSilentPayments(inputPublicKeys));
}
@Test
public void invalidMixedSegwitVersions() {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCuv6p7LnQnsTi6F7UesBJ3TJOXBoHuPhb2Y3hjUklAJAEPBAAAAAABASughgEAAAAAACJSIIYiz2yIzTx5hSOFG+T1WYNgEYY+sDBYObtzXueh9KPpARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCBhzAQAAAAAAAQQiUSAhnr95bXw7ml6Di0tr4pd8dOuNav5vT1iABrPWw2HrfAEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=";
Assertions.assertThrows(PSBTParseException.class, () -> PSBT.fromString(psbt));
}
@Test
public void invalidNoEcdhShares() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiAkKwMCMdEgYFJ2eRmpLxfpzUNydadt47rD+2VnyyekwwEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAAAQMIGHMBAAAAAAABBCJRIATj08VSBMBgg9/eWjaW0ZzOJ28erl5eCzDe6FqZrylWAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
Assertions.assertThrows(PSBTProofException.class, () -> parsed.validateSilentPayments(inputPublicKeys));
}
@Test
public void invalidGlobalEcdhShareWithoutDleq() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/wABDiD2W3/BmfoPsrzNsfoGEte1D2K0+PqV1WXEoB9MWC6SpAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAAAQMIGHMBAAAAAAABBCJRIIEAAjnkcLtSN9n6vrAZ4nmPxFcueck5C10dXjYbt+AgAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
Assertions.assertThrows(PSBTProofException.class, () -> parsed.validateSilentPayments(inputPublicKeys));
}
@Test
public void invalidWrongSpV0InfoSize() {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiBhb07a0EqylXgp4bK5tBHX2y2Sc2xQAy4NalHiyZy13gEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCBhzAQAAAAAAAQQiUSCrH00eEXl4YXYOd4rbbMFm+Wq2HdGvh5nmUfQVMiHskwEJQQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEAA==";
Assertions.assertThrows(PSBTParseException.class, () -> PSBT.fromString(psbt));
}
@Test
public void invalidWrongEcdhShareSize() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiC1ovhHRrNOgzq0ftE7S5hwtBwhRvo0a3NKnEftiiROwwEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CACVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5iwABAwgYcwEAAAAAAAEEIlEggDCSSC8mKdKdmFkQsILGjKIHwO1APY2HsBzp079x+AEBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA";
Assertions.assertThrows(PSBTParseException.class, () -> PSBT.fromString(psbt));
}
@Test
public void invalidWrongDleqSize() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiA78BpakVw0yvjzRht09tYBDcMoUSAE43p653aHic5dEwEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQMIGHMBAAAAAAABBCJRIO10/QbweN3s8W6gOiwTeFJjNgwtIQhX77ERqoAxaa37AQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==";
Assertions.assertThrows(PSBTParseException.class, () -> PSBT.fromString(psbt));
}
@Test
public void invalidAddressMismatch() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiBumss8ldbK7k1KciUCaeuPPQVy7U+E9Lf8M6mavj46BQEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCBhzAQAAAAAAAQQiUSDN452LBbSW+PGPILJ9CvmjIzMJ4EciGeCBP/O103h+KQEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
Assertions.assertThrows(PSBTProofException.class, () -> parsed.validateSilentPayments(inputPublicKeys));
}
@Test
public void validMixedInputTypes() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAIAAAABBQQBAAAAAQYBAwABDiApw6Peut1f6dS0XD295x0CWioK+UM7tO+6i0Pv5za0oQEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIeAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gtsz2qm3pd5sGLvsbwVMcZaXnsq6QVaZiqwVAbw30R9QBDwQAAAAAARAE/v///wEAUwIAAAAB5D9MfaDZ74MIM0F6Sy1pXgWgdanUwFY3tfzq3xlflbIAAAAAAP////8B8EkCAAAAAAAXqRRjd6+3VLmKdhIZzxZ2/JLCN4RQtocAAAAAAQRHUiECjx0ILWAB+kuomaQD2vmx2wG+klwiUzr2MO5kk7C+n3khAzWJfsWucE/lXY6l1Gewrr2zeZvhDDYEcdm3icgRr947Uq4AAQMIkF8BAAAAAAABBCJRIOwCLPQ2taebtxfBsueBADxLQswPLMCEEteHBizKo0eDAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
parsed.validateSilentPayments(inputPublicKeys);
}
@Test
public void validBothGlobalAndInputEcdh() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gXPiMRKRM1SJjYb7RB1GUb5+HCVIpEeB3gV9ffweFr2kBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQABAwQBAAAAIh0C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPghAlUWTnkm1Q1SoJ/5kGR6XpXB2xv8aKYW+8LaITkn+Yv/Ih4C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPhAwdZ/OHiCv4F5FeoVgh1k1bODXB/AXFWM49gkkqF9kIwQSH27m93BCAyMLZwZcbJmCDxpBU4iGT8kQSisZnN+YAABAwgYcwEAAAAAAAEEIlEgS6fi6UvLAo37uSgZaLV+3Cn2cFex/LIX3bRQwxpRxwwBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
parsed.validateSilentPayments(inputPublicKeys);
}
@Test
public void validSingleSignerGlobalEcdh() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gbprLPJXWyu5NSnIlAmnrjz0Fcu1PhPS3/DOpmr4+OgUBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQABAwQBAAAAAAEDCBhzAQAAAAAAAQQiUSCuGfvuJzChqVLX0lmMxwP93zuXKyUUix7Rp5roc51eBwEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
parsed.validateSilentPayments(inputPublicKeys);
}
@Test
public void validMultiPartyPerInputEcdh() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAIAAAABBQQBAAAAAQYBAwABDiBc+IxEpEzVImNhvtEHUZRvn4cJUikR4HeBX19/B4WvaQEPBAAAAAABAR9QwwAAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEOIBPS7rrZoQmRR0EMhqb+CyDunbdZ2kHP1O6Xks1QEjcVAQ8EAAAAAAEBH1DDAAAAAAAAFgAUQhxxWu35g68OO2dv98SU0QVPzboBEAT+////IgYCjx0ILWAB+kuomaQD2vmx2wG+klwiUzr2MO5kk7C+n3kAAQMEAQAAACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQNb6DQQKcckf6K5ONXUuFs6afvZMXutha9leZJx3LQoASIeAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMD+LMxS37uSW9P/ZUt6ltjsDgMaZ9BBtPmcsOgRSeKHqCvT1RvA/3M6zn0yq2PWEhhlRXeAj7LH+wbSvgb/OrYAAQMIGHMBAAAAAAABBCJRIAvcah2ruHUXZ4wrX33N/yl23F0RNcTdk/ZnI+vvuY4lAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
TransactionInput input1 = parsed.getTransaction().getInputs().get(1);
ECKey pubKey1 = ECKey.fromPublicOnly(Hex.decode("028f1d082d6001fa4ba899a403daf9b1db01be925c22533af630ee6493b0be9f79"));
inputPublicKeys.put(input1, pubKey1);
parsed.validateSilentPayments(inputPublicKeys);
}
@Test
public void validSilentPaymentWithChange() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQCAAAAAQYBAwABDiAlbK6m2hWAb7hW7a50mI1EDHqxtcCGHsgR0ZCSdHudHAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCFDDAAAAAAAAAQQiUSBVuRZLw33Ib1uJNhaCqjCItbP6U9rbQ+lfG9ETm7HANQEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AP1JENIUgFlZrxF0fpqXFoYYs3ZM/FchDtCcjgi9SUyNwEKBAEAAAAAAQMIyK8AAAAAAAABBBYAFOPDEMwq86xuYsrkvSPj7lK54clZIgID01f3wHGPJHjj/Y+MzCcp3djAzK6x8CsYGm9E1DufjY0MAAAAAAAAAAAAAAABAA==";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
parsed.validateSilentPayments(inputPublicKeys);
}
@Test
public void validMultipleOutputsSameScanKey() throws PSBTParseException {
String psbt = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQCAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gLHvTL/FQccCuAyc4ZKFDbIpWITVp4RMtz46nPsjDMiIBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQABAwQBAAAAAAEDCECcAAAAAAAAAQQiUSD7K3E6/VK6JHGBuZhBqk8siFW6332s4usuMFXaE+4rjAEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwABAwjY1gAAAAAAAAEEIlEgWaCp4b2YmHQgE1U4YO0LRLcm+8Ug6cVxvEZJXyR59hgBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA";
PSBT parsed = PSBT.fromString(psbt);
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
TransactionInput input0 = parsed.getTransaction().getInputs().get(0);
ECKey pubKey0 = ECKey.fromPublicOnly(Hex.decode("03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d"));
inputPublicKeys.put(input0, pubKey0);
parsed.validateSilentPayments(inputPublicKeys);
}
@AfterEach
public void tearDown() throws Exception {
public void tearDown() {
Network.set(null);
}
}