Compare commits

..

12 Commits
sp ... master

14 changed files with 1108 additions and 29 deletions

View File

@ -14,6 +14,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
@ -93,6 +94,15 @@ public class Utils {
return ((st > 0) || (len < bytes.length)) ? Arrays.copyOfRange(bytes, st, len) : bytes;
}
public static boolean isSecureUrl(URI uri) {
if(uri == null || uri.getScheme() == null || uri.getHost() == null) {
return false;
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
return "https".equals(scheme) || ("http".equals(scheme) && uri.getHost().toLowerCase(Locale.ROOT).endsWith(".onion"));
}
public static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for ( int j = 0; j < bytes.length; j++ ) {

View File

@ -1,5 +1,6 @@
package com.sparrowwallet.drongo.crypto;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.policy.PolicyType;
@ -15,6 +16,10 @@ import java.util.*;
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
public class Bip322 {
public static final String SIMPLE_PREFIX = "smp";
public static final String FULL_PREFIX = "ful";
public static final String FULL_POF_PREFIX = "pof";
public static String signMessageBip322(ScriptType scriptType, String message, ECKey privKey) {
checkScriptType(scriptType);
@ -36,6 +41,7 @@ public class Bip322 {
TransactionOutput utxoOutput = toSpend.getOutputs().getFirst();
PSBT psbt = new PSBT(toSign);
psbt.setGenericSignedMessage(message);
PSBTInput psbtInput = psbt.getPsbtInputs().getFirst();
psbtInput.setWitnessUtxo(utxoOutput);
psbtInput.setSigHash(SigHash.ALL);
@ -43,6 +49,46 @@ public class Bip322 {
return psbt;
}
public static PSBT getBip322PsbtSp(Address address, String message, byte[] silentPaymentsTweak, Map<ECKey, KeyDerivation> spendDerivations) {
if(silentPaymentsTweak == null) {
throw new IllegalArgumentException("Silent payments tweak is required");
}
PSBT psbt = getBip322Psbt(P2TR, address, message);
PSBTInput psbtInput = psbt.getPsbtInputs().getFirst();
psbtInput.setSilentPaymentsTweak(silentPaymentsTweak);
if(spendDerivations != null && !spendDerivations.isEmpty()) {
psbtInput.getSilentPaymentsSpendDerivations().putAll(spendDerivations);
}
return psbt;
}
public static String signMessageBip322Sp(Address address, String message, ECKey spendPrivKey, byte[] silentPaymentsTweak) {
PSBT psbt = getBip322PsbtSp(address, message, silentPaymentsTweak, Collections.emptyMap());
PSBTInput psbtInput = psbt.getPsbtInputs().getFirst();
if(!psbtInput.signSilentPayments(spendPrivKey)) {
throw new IllegalStateException("Failed to sign BIP322 PSBT with silent payments tweak");
}
return getBip322SignatureFromPsbtSp(psbt);
}
public static String getBip322SignatureFromPsbtSp(PSBT signedPsbt) {
PSBTInput psbtInput = signedPsbt.getPsbtInputs().getFirst();
TransactionSignature signature = psbtInput.getTapKeyPathSignature();
if(signature == null) {
throw new IllegalArgumentException("PSBT does not contain a taproot keypath signature");
}
Transaction finalizeTransaction = new Transaction();
TransactionWitness witness = new TransactionWitness(finalizeTransaction, signature);
finalizeTransaction.addInput(Sha256Hash.ZERO_HASH, 0, new Script(new byte[0]), witness);
return SIMPLE_PREFIX + Base64.getEncoder().encodeToString(witness.toByteArray());
}
public static String getBip322SignatureFromPsbt(ScriptType scriptType, PSBT signedPsbt, ECKey pubKey) {
checkScriptType(scriptType);
@ -59,19 +105,28 @@ public class Bip322 {
TransactionWitness witness = psbtInput.isTaproot() ? new TransactionWitness(finalizeTransaction, signature) : new TransactionWitness(finalizeTransaction, pubKey, signature);
TransactionInput finalizedTxInput = finalizeTransaction.addInput(Sha256Hash.ZERO_HASH, 0, scriptSig, witness);
return Base64.getEncoder().encodeToString(finalizedTxInput.getWitness().toByteArray());
return SIMPLE_PREFIX + Base64.getEncoder().encodeToString(finalizedTxInput.getWitness().toByteArray());
}
public static boolean verifyMessageBip322(ScriptType scriptType, Address address, String message, String signatureBase64) throws SignatureException {
checkScriptType(scriptType);
if(signatureBase64.trim().isEmpty()) {
String trimmed = signatureBase64.trim();
if(trimmed.isEmpty()) {
throw new SignatureException("Provided signature is empty.");
}
if(trimmed.startsWith(FULL_PREFIX) || trimmed.startsWith(FULL_POF_PREFIX)) {
throw new SignatureException("Only the simple BIP322 signature variant is supported.");
}
if(trimmed.startsWith(SIMPLE_PREFIX)) {
trimmed = trimmed.substring(SIMPLE_PREFIX.length());
}
byte[] signatureEncoded;
try {
signatureEncoded = Base64.getDecoder().decode(signatureBase64);
signatureEncoded = Base64.getDecoder().decode(trimmed);
} catch(IllegalArgumentException e) {
throw new SignatureException("Could not decode base64 signature", e);
}

View File

@ -13,6 +13,7 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
@ -32,6 +33,7 @@ public class PSBT {
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_GENERIC_SIGNED_MESSAGE = 0x09;
public static final byte PSBT_GLOBAL_VERSION = (byte)0xfb;
public static final byte PSBT_GLOBAL_PROPRIETARY = (byte)0xfc;
@ -50,6 +52,7 @@ public class PSBT {
private Integer version = null;
private final Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
private String genericSignedMessage = null;
private final Map<String, String> globalProprietary = new LinkedHashMap<>();
//PSBTv0-only fields
@ -434,6 +437,11 @@ public class PSBT {
this.silentPaymentsDLEQProofs.put(proofScanKey, dleqProof);
log.debug("PSBT global silent payments DLEQ proof for scan key: " + Utils.bytesToHex(entry.getKeyData()));
break;
case PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE:
entry.checkOneByteKey();
this.genericSignedMessage = new String(entry.getData(), StandardCharsets.UTF_8);
log.debug("PSBT global generic signed message: " + genericSignedMessage);
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()));
@ -663,6 +671,40 @@ public class PSBT {
}
}
public void verifySigHashes() throws PSBTSignatureException {
PSBTSignatureException worst = null;
int worstSeverity = 0;
for(PSBTInput input : psbtInputs) {
try {
input.verifySigHash();
} catch(PSBTSignatureException e) {
int severity = sigHashSeverity(input.getSigHash());
if(severity > worstSeverity) {
worstSeverity = severity;
worst = e;
}
}
}
if(worst != null) {
throw worst;
}
}
private static int sigHashSeverity(SigHash sigHash) {
if(sigHash == null) {
return 0;
}
return switch(sigHash) {
case NONE, ANYONECANPAY_NONE -> 5;
case ANYONECANPAY_SINGLE -> 4;
case SINGLE -> 3;
case ANYONECANPAY_ALL -> 2;
case ANYONECANPAY -> 1;
default -> 0;
};
}
public void validateSilentPayments(Transaction extractedTransaction) throws PSBTProofException {
Map<HashIndex, Script> spentScriptPubKeys = getPsbtInputs().stream()
.collect(Collectors.toMap(psbtInput -> new HashIndex(psbtInput.getPrevTxid(), psbtInput.getPrevIndex()), psbtInput -> psbtInput.getUtxo().getScript()));
@ -875,6 +917,10 @@ public class PSBT {
entries.add(populateEntry(PSBT_GLOBAL_VERSION, null, versionBytes));
}
if(genericSignedMessage != null) {
entries.add(populateEntry(PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, null, genericSignedMessage.getBytes(StandardCharsets.UTF_8)));
}
for(Map.Entry<String, String> entry : globalProprietary.entrySet()) {
entries.add(populateEntry(PSBT_GLOBAL_PROPRIETARY, Utils.hexToBytes(entry.getKey()), Utils.hexToBytes(entry.getValue())));
}
@ -932,6 +978,21 @@ public class PSBT {
PSBT verificationCopy = this.copy();
verificationCopy.combine(psbt);
verificationCopy.verifySignatures();
verifyCombinedSigHashes(verificationCopy);
}
private void verifyCombinedSigHashes(PSBT verificationCopy) throws PSBTSignatureException {
for(int i = 0; i < getPsbtInputs().size(); i++) {
PSBTInput thisInput = getPsbtInputs().get(i);
PSBTInput otherInput = verificationCopy.getPsbtInputs().get(i);
if(sigHashSeverity(otherInput.getSigHash()) > sigHashSeverity(thisInput.getSigHash())) {
try {
otherInput.verifySigHash();
} catch(PSBTSignatureException e) {
throw new PSBTSignatureException("Combined PSBT would change sighash: " + e.getMessage());
}
}
}
}
public void combine(PSBT... psbts) {
@ -1226,6 +1287,14 @@ public class PSBT {
return globalProprietary;
}
public String getGenericSignedMessage() {
return genericSignedMessage;
}
public void setGenericSignedMessage(String genericSignedMessage) {
this.genericSignedMessage = genericSignedMessage;
}
public String toString() {
return Utils.bytesToHex(serialize());
}

View File

@ -923,6 +923,12 @@ public class PSBTInput {
if(getNonWitnessUtxo() != null || getWitnessUtxo() != null) {
Script signingScript = getSigningScript();
if(signingScript != null) {
if((localSigHash == SigHash.SINGLE || localSigHash == SigHash.ANYONECANPAY_SINGLE) && index >= psbt.getTransaction().getOutputs().size()
&& Arrays.asList(NON_WITNESS_TYPES).contains(getScriptType())) {
throw new IllegalStateException("Refusing to sign SIGHASH_SINGLE on legacy input " + index
+ " with only " + psbt.getTransaction().getOutputs().size() + " output(s) as it would produce a re-broadcastable signature");
}
Sha256Hash hash = getHashForSignature(signingScript, localSigHash);
TransactionSignature.Type type = isTaproot() ? SCHNORR : ECDSA;
TransactionSignature transactionSignature = psbtInputSigner.sign(hash, localSigHash, type);
@ -941,6 +947,27 @@ public class PSBTInput {
return false;
}
void verifySigHash() throws PSBTSignatureException {
if(sigHash == null || sigHash == SigHash.ALL || sigHash == SigHash.DEFAULT) {
return;
}
switch(sigHash) {
case NONE:
throw new PSBTSignatureException("Input " + index + " requests SIGHASH_NONE. The signature does not commit to any of the outputs, and can be re-used on a transaction with completely different outputs.");
case ANYONECANPAY_NONE:
throw new PSBTSignatureException("Input " + index + " requests SIGHASH_NONE | ANYONECANPAY. The signature commits to neither inputs nor outputs and can be re-used in nearly any transaction.");
case ANYONECANPAY_SINGLE:
throw new PSBTSignatureException("Input " + index + " requests SIGHASH_SINGLE | ANYONECANPAY. The signature only commits to one output, and other inputs may be added after signing.");
case SINGLE:
throw new PSBTSignatureException("Input " + index + " requests SIGHASH_SINGLE. The signature only commits to the output at the same index, allowing other outputs to be added or modified after signing.");
case ANYONECANPAY_ALL:
throw new PSBTSignatureException("Input " + index + " requests SIGHASH_ALL | ANYONECANPAY. Other inputs may be added to the transaction after signing, potentially redirecting value through fees.");
case ANYONECANPAY:
throw new PSBTSignatureException("Input " + index + " requests a non-standard ANYONECANPAY sighash with no base type. The resulting signature has unpredictable commitment semantics.");
}
}
boolean verifySignatures() throws PSBTSignatureException {
SigHash localSigHash = getSigHash();
if(localSigHash == null) {

View File

@ -94,4 +94,17 @@ public class SilentPaymentAddress {
buffer.put(spendAddress.getPubKey());
return buffer.array();
}
public static SilentPaymentAddress fromBytes(byte[] bytes) {
if(bytes.length != 67) {
throw new IllegalArgumentException("Silent payment address must be 67 bytes");
}
int version = bytes[0] & 0xff;
if(version != VERSION) {
throw new UnsupportedOperationException("Unsupported silent payments address version " + version);
}
ECKey scanPubKey = ECKey.fromPublicOnly(Arrays.copyOfRange(bytes, 1, 34));
ECKey spendPubKey = ECKey.fromPublicOnly(Arrays.copyOfRange(bytes, 34, 67));
return new SilentPaymentAddress(scanPubKey, spendPubKey);
}
}

View File

@ -331,6 +331,20 @@ public class SilentPaymentUtils {
return ECKey.fromPrivate(summedPrivKey);
}
public static ECKey getInputPublicKey(WalletNode walletNode) {
if(!walletNode.getWallet().canSendSilentPayments()) {
return null;
}
ECKey rawKey = walletNode.getPubKey();
ECKey publicKey = walletNode.getWallet().getScriptType().getOutputKey(walletNode.getWallet().getPolicyType(), rawKey);
if(walletNode.getWallet().getScriptType() == P2TR && publicKey.hasOddYCoord()) {
publicKey = publicKey.negate();
}
return publicKey;
}
public static ECKey getSummedPublicKey(Collection<ECKey> publicKeys) {
ECKey summedKey = null;

View File

@ -1,6 +1,7 @@
package com.sparrowwallet.drongo.uri;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
@ -275,10 +276,10 @@ public class BitcoinURI {
if(payjoinUrl != null) {
try {
URI uri = new URI(payjoinUrl);
if(uri.getScheme().equals("https") || uri.getHost().endsWith(".onion")) {
if(Utils.isSecureUrl(uri)) {
return uri;
} else {
log.error("Insecure payjoin URL provided, must be https or .onion: " + payjoinUrl);
log.error("Insecure payjoin URL provided, must be https or http .onion: " + payjoinUrl);
}
} catch(URISyntaxException e) {
log.error("Invalid payjoin URL provided", e);

View File

@ -13,6 +13,7 @@ import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTOutput;
import com.sparrowwallet.drongo.psbt.PSBTProofException;
import com.sparrowwallet.drongo.silentpayments.*;
import java.nio.charset.StandardCharsets;
@ -497,21 +498,24 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
}
public SilentPaymentAddress getSilentPaymentAddress(Address address) {
return silentPaymentAddresses.get(address);
return resolveMasterWallet().silentPaymentAddresses.get(address);
}
private void addSilentPaymentAddress(Address address, SilentPaymentAddress silentPaymentAddress) {
silentPaymentAddresses.put(address, silentPaymentAddress);
public void addSilentPaymentAddress(Address address, SilentPaymentAddress silentPaymentAddress) {
resolveMasterWallet().silentPaymentAddresses.put(address, silentPaymentAddress);
}
public Map<Address, SilentPaymentAddress> getSilentPaymentAddresses() {
return resolveMasterWallet().silentPaymentAddresses;
}
public void clearSilentPaymentAddress(Address address) {
silentPaymentAddresses.remove(address);
resolveMasterWallet().silentPaymentAddresses.remove(address);
}
public boolean isSilentPaymentsTransaction(BlockTransaction blockTransaction) {
Wallet wallet = isNested() ? getMasterWallet() : this;
return blockTransaction.getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress())
.filter(Objects::nonNull).anyMatch(address -> wallet.getSilentPaymentAddress(address) != null);
.filter(Objects::nonNull).anyMatch(address -> getSilentPaymentAddress(address) != null);
}
public boolean isSafeToAddInputsOrOutputs(BlockTransaction blockTransaction) {
@ -601,6 +605,20 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
this.birthHeight = birthHeight;
}
public int getNeededScanStart() {
if(storedBlockHeight != null && storedBlockHeight > 0) {
return Math.max(0, storedBlockHeight - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM);
}
if(birthHeight != null) {
return Math.max(0, birthHeight - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM);
}
if(birthDate != null) {
return (int)(birthDate.getTime() / 1000L);
}
return 0;
}
public boolean isMasterWallet() {
return masterWallet == null;
}
@ -613,6 +631,10 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
this.masterWallet = masterWallet;
}
public Wallet resolveMasterWallet() {
return isNested() ? getMasterWallet() : this;
}
public Wallet getChildWallet(String name) {
return childWallets.stream().filter(wallet -> wallet.getName().equals(name)).findFirst().orElse(null);
}
@ -1760,6 +1782,16 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
}
public void sign(PSBT psbt) throws MnemonicException {
if(psbt.getPsbtOutputs().stream().anyMatch(o -> o.getSilentPaymentAddress() != null)) {
List<PSBTInput> psbtInputs = psbt.getPsbtInputs();
for(int i = 0; i < psbtInputs.size(); i++) {
SigHash inputSigHash = psbtInputs.get(i).getSigHash();
if(inputSigHash != null && inputSigHash != SigHash.ALL && inputSigHash != SigHash.DEFAULT) {
throw new IllegalStateException("Silent payment outputs require SIGHASH_ALL/DEFAULT signatures. Input at index " + i + " has sighash type: " + inputSigHash);
}
}
}
sign(getSigningNodes(psbt));
}
@ -1784,14 +1816,113 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
}
}
public List<SilentPayment> computeSilentPaymentOutputs(PSBT psbt, Map<PSBTInput, WalletNode> signingNodes) throws InvalidSilentPaymentException {
List<PSBTOutput> silentOutputs = psbt.getPsbtOutputs().stream().filter(psbtOutput -> psbtOutput.getSilentPaymentAddress() != null).collect(Collectors.toList());
if(!silentOutputs.isEmpty()) {
/**
* Returns the subset of PSBT_OUT_SP_V0_INFO entries whose claimed silent payment address
* actually derives the PSBT output script under BIP-352, as proven by the PSBT's BIP-375
* ECDH shares and DLEQ proofs (global or per-input form) against this wallet's input public
* keys. Verification uses public keys only and works for both software and hardware keystores.
* Returns an empty map if any input is not from this wallet, if BIP-375 metadata is missing
* or inconsistent, if any DLEQ proof fails to verify, or if any claimed script does not
* match the BIP-352 derivation.
*/
public Map<Address, SilentPaymentAddress> verifySilentPaymentOutputs(PSBT psbt) {
Map<Address, SilentPaymentAddress> verified = new LinkedHashMap<>();
if(psbt == null) {
return verified;
}
List<PSBTOutput> spOutputs = new ArrayList<>();
for(PSBTOutput psbtOutput : psbt.getPsbtOutputs()) {
if(psbtOutput.getSilentPaymentAddress() == null) {
continue;
}
Script script = psbtOutput.getScript();
if(script == null || script.getToAddress() == null) {
return verified;
}
spOutputs.add(psbtOutput);
}
if(spOutputs.isEmpty()) {
return verified;
}
try {
Map<PSBTInput, WalletNode> signingNodes = getSigningNodes(psbt);
if(psbt.getPsbtInputs().size() != signingNodes.size()) {
throw new InvalidSilentPaymentException("All inputs must be from wallet to calculate silent payment addresses");
return verified;
}
List<SilentPayment> silentPayments = silentOutputs.stream()
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
Transaction transaction = psbt.getTransaction();
for(int i = 0; i < psbt.getPsbtInputs().size(); i++) {
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
WalletNode node = signingNodes.get(psbtInput);
ECKey publicKey = SilentPaymentUtils.getInputPublicKey(node);
if(publicKey == null) {
return verified;
}
inputPublicKeys.put(transaction.getInputs().get(i), publicKey);
}
psbt.validateSilentPayments(inputPublicKeys);
for(PSBTOutput psbtOutput : spOutputs) {
verified.put(psbtOutput.getScript().getToAddress(), psbtOutput.getSilentPaymentAddress());
}
} catch(Exception e) {
return new LinkedHashMap<>();
}
return verified;
}
public List<SilentPayment> computeSilentPaymentOutputs(PSBT psbt, Map<PSBTInput, WalletNode> signingNodes) throws InvalidSilentPaymentException {
List<PSBTOutput> silentOutputs = psbt.getPsbtOutputs().stream().filter(psbtOutput -> psbtOutput.getSilentPaymentAddress() != null).collect(Collectors.toList());
if(silentOutputs.isEmpty()) {
return Collections.emptyList();
}
if(psbt.getPsbtInputs().size() != signingNodes.size()) {
throw new InvalidSilentPaymentException("All inputs must be from wallet to calculate silent payment addresses");
}
List<PSBTOutput> preComputedOutputs = new ArrayList<>();
List<PSBTOutput> computeOutputs = new ArrayList<>();
for(PSBTOutput silentOutput : silentOutputs) {
Script script = silentOutput.getScript();
if(script != null && script.getToAddress() != null) {
preComputedOutputs.add(silentOutput);
} else {
computeOutputs.add(silentOutput);
}
}
List<SilentPayment> results = new ArrayList<>();
if(!preComputedOutputs.isEmpty()) {
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
Transaction transaction = psbt.getTransaction();
for(int i = 0; i < psbt.getPsbtInputs().size(); i++) {
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
ECKey publicKey = SilentPaymentUtils.getInputPublicKey(signingNodes.get(psbtInput));
if(publicKey == null) {
throw new InvalidSilentPaymentException("Cannot derive input public key for silent payment verification");
}
inputPublicKeys.put(transaction.getInputs().get(i), publicKey);
}
try {
psbt.validateSilentPayments(inputPublicKeys);
} catch(PSBTProofException e) {
throw new InvalidSilentPaymentException("Silent payment metadata in PSBT failed BIP-375 verification: " + e.getMessage());
}
for(PSBTOutput silentOutput : preComputedOutputs) {
results.add(new SilentPayment(silentOutput.getSilentPaymentAddress(), silentOutput.getScript().getToAddress(), null, silentOutput.getAmount(), false));
}
}
if(!computeOutputs.isEmpty()) {
List<SilentPayment> silentPayments = computeOutputs.stream()
.map(psbtOutput -> new SilentPayment(psbtOutput.getSilentPaymentAddress(), null, psbtOutput.getAmount(), false)).collect(Collectors.toList());
Map<HashIndex, WalletNode> utxos = signingNodes.keySet().stream()
.collect(Collectors.toMap(psbtInput -> new HashIndex(psbtInput.getPrevTxid(), psbtInput.getPrevIndex()), signingNodes::get));
@ -1801,8 +1932,8 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
psbt.getSilentPaymentsDLEQProofs().put(key, ecdhShareAndProof.dleqProof());
});
for(int i = 0; i < silentOutputs.size(); i++) {
PSBTOutput silentOutput = silentOutputs.get(i);
for(int i = 0; i < computeOutputs.size(); i++) {
PSBTOutput silentOutput = computeOutputs.get(i);
SilentPayment silentPayment = silentPayments.get(i);
if(!silentPayment.isAddressComputed()) {
throw new InvalidSilentPaymentException("Silent payment address was not calculated");
@ -1810,13 +1941,12 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
Script outputScript = silentPayment.getAddress().getOutputScript();
silentOutput.setScript(outputScript);
addSilentPaymentAddress(silentPayment.getAddress(), silentPayment.getSilentPaymentAddress());
}
return silentPayments;
results.addAll(silentPayments);
}
return Collections.emptyList();
return results;
}
public void finalise(PSBT psbt) {
@ -1882,12 +2012,80 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
psbtInput.setFinalScriptSig(finalizedTxInput.getScriptSig());
psbtInput.setFinalScriptWitness(finalizedTxInput.getWitness());
psbtInput.clearNonFinalFields();
} else if(signingNode == null) {
finaliseExternalInput(psbtInput, utxo, signaturesAvailable);
}
}
psbt.getPsbtOutputs().forEach(PSBTOutput::clearNonFinalFields);
}
private void finaliseExternalInput(PSBTInput psbtInput, TransactionOutput utxo, int signaturesAvailable) {
Script signingScript = psbtInput.getSigningScript();
ScriptType inputScriptType = psbtInput.getScriptType();
if(signingScript == null || inputScriptType == null) {
return;
}
int inputThreshold;
try {
inputThreshold = signingScript.getNumRequiredSignatures();
} catch(NonStandardScriptException e) {
return;
}
if(signaturesAvailable < inputThreshold) {
return;
}
Transaction transaction = new Transaction();
TransactionInput finalizedTxInput;
if(MULTISIG.isScriptType(signingScript)) {
ECKey[] scriptPubKeys = MULTISIG.getPublicKeysFromScript(signingScript);
Map<ECKey, TransactionSignature> pubKeySignatures = new TreeMap<>(new ECKey.LexicographicECKeyComparator());
for(ECKey pubKey : scriptPubKeys) {
pubKeySignatures.put(pubKey, psbtInput.getPartialSignature(pubKey));
}
long matched = pubKeySignatures.values().stream().filter(Objects::nonNull).count();
if(matched < inputThreshold) {
return;
}
finalizedTxInput = inputScriptType.addMultisigSpendingInput(PolicyType.MULTI_HD, transaction, utxo, inputThreshold, pubKeySignatures);
} else if(inputThreshold == 1) {
ECKey pubKey;
TransactionSignature signature;
PolicyType policyType;
if(psbtInput.isTaproot()) {
//The witness program is the tweaked output key - pass it as SINGLE_SP so getOutputKey does not tweak again
policyType = PolicyType.SINGLE_SP;
pubKey = P2TR.getPublicKeyFromScript(utxo.getScript());
signature = psbtInput.getTapKeyPathSignature();
} else if(psbtInput.getPartialSignatures().size() == 1) {
policyType = PolicyType.SINGLE_HD;
Map.Entry<ECKey, TransactionSignature> entry = psbtInput.getPartialSignatures().entrySet().iterator().next();
pubKey = entry.getKey();
signature = entry.getValue();
} else {
return;
}
if(signature == null) {
return;
}
finalizedTxInput = inputScriptType.addSpendingInput(policyType, transaction, utxo, pubKey, signature);
} else {
return;
}
psbtInput.setFinalScriptSig(finalizedTxInput.getScriptSig());
psbtInput.setFinalScriptWitness(finalizedTxInput.getWitness());
psbtInput.clearNonFinalFields();
}
public BitcoinUnit getAutoUnit() {
for(KeyPurpose keyPurpose : KeyPurpose.values()) {
for(WalletNode addressNode : getNode(keyPurpose).getChildren()) {

View File

@ -0,0 +1,43 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: Hostname:
Version: Hockeypuck 2.2
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
yQdYDjoIbgfDCQ7KTnaRHQ==
=32hh
-----END PGP PUBLIC KEY BLOCK-----

View File

@ -0,0 +1,48 @@
package com.sparrowwallet.drongo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.net.URI;
public class UtilsTest {
@Test
public void isSecureUrlAcceptsHttps() throws Exception {
Assertions.assertTrue(Utils.isSecureUrl(new URI("https://example.com/callback")));
}
@Test
public void isSecureUrlAcceptsUppercaseHttps() throws Exception {
Assertions.assertTrue(Utils.isSecureUrl(new URI("HTTPS://example.com/callback")));
}
@Test
public void isSecureUrlAcceptsHttpOnion() throws Exception {
Assertions.assertTrue(Utils.isSecureUrl(new URI("http://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/callback")));
}
@Test
public void isSecureUrlRejectsHttpClearnet() throws Exception {
Assertions.assertFalse(Utils.isSecureUrl(new URI("http://example.com/callback")));
}
@Test
public void isSecureUrlRejectsNonHttpOnion() throws Exception {
Assertions.assertFalse(Utils.isSecureUrl(new URI("ftp://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/callback")));
}
@Test
public void isSecureUrlRejectsOpaqueHttps() throws Exception {
Assertions.assertFalse(Utils.isSecureUrl(new URI("https:opaque")));
}
@Test
public void isSecureUrlRejectsSchemeless() throws Exception {
Assertions.assertFalse(Utils.isSecureUrl(new URI("callback")));
}
@Test
public void isSecureUrlRejectsNull() {
Assertions.assertFalse(Utils.isSecureUrl(null));
}
}

View File

@ -30,10 +30,10 @@ public class Bip322Test {
Assertions.assertEquals("bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l", address.toString());
String signature = Bip322.signMessageBip322(ScriptType.P2WPKH, "", privKey);
Assertions.assertEquals("AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=", signature);
Assertions.assertEquals("smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=", signature);
String signature2 = Bip322.signMessageBip322(ScriptType.P2WPKH, "Hello World", privKey);
Assertions.assertEquals("AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=", signature2);
Assertions.assertEquals("smpAkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=", signature2);
}
@Test
@ -49,7 +49,7 @@ public class Bip322Test {
public void verifyMessageBip322() throws InvalidAddressException, SignatureException {
Address address = Address.fromString("bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l");
String message1 = "";
String signature1 = "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=";
String signature1 = "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=";
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, message1, signature1));
@ -57,10 +57,20 @@ public class Bip322Test {
String signature2 = "AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=";
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, message2, signature2));
String signature3 = "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy";
String signature3 = "smpAkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy";
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, message2, signature3));
}
@Test
public void verifyMessageBip322RejectsFullVariant() throws InvalidAddressException {
Address address = Address.fromString("bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l");
String fullSignature = "fulAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=";
Assertions.assertThrows(SignatureException.class, () -> Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, "", fullSignature));
String pofSignature = "pofAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=";
Assertions.assertThrows(SignatureException.class, () -> Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, "", pofSignature));
}
@Test
public void signMessageBip322Taproot() {
ECKey privKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey();
@ -68,7 +78,7 @@ public class Bip322Test {
Assertions.assertEquals("bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3", address.toString());
String signature = Bip322.signMessageBip322(ScriptType.P2TR, "Hello World", privKey);
Assertions.assertEquals("AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==", signature);
Assertions.assertEquals("smpAUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==", signature);
}
@Test
@ -78,9 +88,11 @@ public class Bip322Test {
Assertions.assertEquals("bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3", address.toString());
String message1 = "Hello World";
String signature1 = "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==";
String signaturePrefixed = "smpAUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==";
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2TR, address, message1, signaturePrefixed));
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2TR, address, message1, signature1));
String signatureLegacy = "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==";
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2TR, address, message1, signatureLegacy));
}
@Test
@ -115,6 +127,20 @@ public class Bip322Test {
Assertions.assertNotNull(psbtInput.getWitnessUtxo());
Assertions.assertEquals(SigHash.ALL, psbtInput.getSigHash());
Assertions.assertEquals(0, psbt.getTransaction().getVersion());
Assertions.assertEquals("Hello World", psbt.getGenericSignedMessage());
}
@Test
public void getBip322PsbtMessageRoundTrip() throws com.sparrowwallet.drongo.psbt.PSBTParseException {
ECKey privKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey();
Address address = ScriptType.P2WPKH.getAddress(PolicyType.SINGLE_HD, privKey);
String message = "UTF-8 support: öäüéàè 测试文本 😄";
PSBT psbt = Bip322.getBip322Psbt(ScriptType.P2WPKH, address, message);
Assertions.assertEquals(message, psbt.getGenericSignedMessage());
PSBT roundTrip = new PSBT(psbt.serialize(), false);
Assertions.assertEquals(message, roundTrip.getGenericSignedMessage());
}
@Test
@ -167,12 +193,54 @@ public class Bip322Test {
Assertions.assertThrows(IllegalArgumentException.class, () -> Bip322.getBip322SignatureFromPsbt(ScriptType.P2WPKH, psbt, pubKey));
}
@Test
public void signMessageBip322Sp() throws SignatureException {
ECKey spendPrivKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey();
byte[] tweak = Utils.hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
ECKey spendPubKey = ECKey.fromPublicOnly(spendPrivKey);
ECKey tweakPoint = ECKey.fromPublicOnly(ECKey.fromPrivate(tweak));
ECKey outputKey = spendPubKey.add(tweakPoint, true);
Address address = ScriptType.P2TR.getAddress(PolicyType.SINGLE_SP, outputKey);
String signature = Bip322.signMessageBip322Sp(address, "Hello World", spendPrivKey, tweak);
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2TR, address, "Hello World", signature));
//An SP signature for the same key but a different tweak must not verify against the first address
byte[] tweak2 = Utils.hexToBytes("fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210");
ECKey tweakPoint2 = ECKey.fromPublicOnly(ECKey.fromPrivate(tweak2));
ECKey outputKey2 = spendPubKey.add(tweakPoint2, true);
Address address2 = ScriptType.P2TR.getAddress(PolicyType.SINGLE_SP, outputKey2);
String signature2 = Bip322.signMessageBip322Sp(address2, "Hello World", spendPrivKey, tweak2);
Assertions.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2TR, address2, "Hello World", signature2));
Assertions.assertFalse(Bip322.verifyMessageBip322(ScriptType.P2TR, address, "Hello World", signature2));
}
@Test
public void getBip322PsbtSp() {
ECKey spendPrivKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey();
byte[] tweak = Utils.hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
ECKey spendPubKey = ECKey.fromPublicOnly(spendPrivKey);
ECKey tweakPoint = ECKey.fromPublicOnly(ECKey.fromPrivate(tweak));
ECKey outputKey = spendPubKey.add(tweakPoint, true);
Address address = ScriptType.P2TR.getAddress(PolicyType.SINGLE_SP, outputKey);
PSBT psbt = Bip322.getBip322PsbtSp(address, "Hello World", tweak, java.util.Collections.emptyMap());
Assertions.assertEquals(1, psbt.getPsbtInputs().size());
PSBTInput psbtInput = psbt.getPsbtInputs().get(0);
Assertions.assertNotNull(psbtInput.getWitnessUtxo());
Assertions.assertArrayEquals(tweak, psbtInput.getSilentPaymentsTweak());
Assertions.assertNull(psbtInput.getTapInternalKey());
Assertions.assertTrue(psbtInput.getTapDerivedPublicKeys().isEmpty());
}
@Test
public void verifyMessageBip322Multisig() throws SignatureException, InvalidAddressException {
Address address = Address.fromString("bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3");
String message1 = "This will be a p2wsh 3-of-3 multisig BIP 322 signed message";
String signature1 = "BQBIMEUCIQDQoXvGKLH58exuujBOta+7+GN7vi0lKwiQxzBpuNuXuAIgIE0XYQlFDOfxbegGYYzlf+tqegleAKE6SXYIa1U+uCcBRzBEAiATegywVl6GWrG9jJuPpNwtgHKyVYCX2yfuSSDRFATAaQIgTLlU6reLQsSIrQSF21z3PtUO2yAUseUWGZqRUIE7VKoBSDBFAiEAgxtpidsU0Z4u/+5RB9cyeQtoCW5NcreLJmWXZ8kXCZMCIBR1sXoEinhZE4CF9P9STGIcMvCuZjY6F5F0XTVLj9SjAWlTIQP3dyWvTZjUENWJowMWBsQrrXCUs20Gu5YF79CG5Ga0XSEDwqI5GVBOuFkFzQOGH5eTExSAj2Z/LDV/hbcvAPQdlJMhA17FuuJd+4wGuj+ZbVxEsFapTKAOwyhfw9qpch52JKxbU64=";
String signature1 = "smpBQBIMEUCIQDQoXvGKLH58exuujBOta+7+GN7vi0lKwiQxzBpuNuXuAIgIE0XYQlFDOfxbegGYYzlf+tqegleAKE6SXYIa1U+uCcBRzBEAiATegywVl6GWrG9jJuPpNwtgHKyVYCX2yfuSSDRFATAaQIgTLlU6reLQsSIrQSF21z3PtUO2yAUseUWGZqRUIE7VKoBSDBFAiEAgxtpidsU0Z4u/+5RB9cyeQtoCW5NcreLJmWXZ8kXCZMCIBR1sXoEinhZE4CF9P9STGIcMvCuZjY6F5F0XTVLj9SjAWlTIQP3dyWvTZjUENWJowMWBsQrrXCUs20Gu5YF79CG5Ga0XSEDwqI5GVBOuFkFzQOGH5eTExSAj2Z/LDV/hbcvAPQdlJMhA17FuuJd+4wGuj+ZbVxEsFapTKAOwyhfw9qpch52JKxbU64=";
Assertions.assertThrows(IllegalArgumentException.class, () -> Bip322.verifyMessageBip322(ScriptType.P2TR, address, message1, signature1));
}

View File

@ -4,8 +4,12 @@ import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.Miniscript;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.bouncycastle.util.encoders.Hex;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
@ -294,6 +298,109 @@ public class PSBTTest {
Assertions.assertEquals("0400473044022062eb7a556107a7c73f45ac4ab5a1dddf6f7075fb1275969a7f383efff784bcb202200c05dbb7470dbf2f08557dd356c7325c1ed30913e996cd3840945db12228da5f01473044022065f45ba5998b59a27ffe1a7bed016af1f1f90d54b3aa8f7450aa5f56a25103bd02207f724703ad1edb96680b284b56d4ffcb88f7fb759eabbe08aa30f29b851383d20147522103089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc21023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7352ae", Hex.toHexString(psbt1.getPsbtInputs().get(1).getFinalScriptWitness().toByteArray()));
}
@Test
public void finaliseExternalMultisigInput() throws PSBTParseException {
Network.set(Network.REGTEST);
String psbtBase64 = "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";
PSBT psbt = PSBT.fromString(psbtBase64);
//Input 0: 2-of-3 P2WSH with both required signatures present
//Input 1: 1-of-2 P2WSH with the one required signature present
Wallet wallet = new Wallet("test") {
@Override
public Policy getDefaultPolicy() {
return new Policy(new Miniscript("")) {
@Override
public int getNumSignaturesRequired() {
return 1;
}
};
}
@Override
public java.util.Map<PSBTInput, com.sparrowwallet.drongo.wallet.WalletNode> getSigningNodes(PSBT psbt) {
return java.util.Collections.emptyMap();
}
@Override
public java.util.Map<PSBTInput, com.sparrowwallet.drongo.wallet.WalletNode> getSigningNodes(PSBT psbt, boolean useDerivationFallback) {
return java.util.Collections.emptyMap();
}
};
wallet.finalise(psbt);
Assertions.assertTrue(psbt.isFinalized(), "Both inputs should be finalised");
String expectedInput0Witness = "04004730440220396b5523e3477e2991b6ff6d43c483211ef7dc27971967384a3f7f74be84dbf502201099ca19a5526cdff6557fa42a0def5b9168535c5855afc14c8388c28b7e1f45014730440220202f28a498aaa34e899fe2823a1a44c49d30bda759bacff335a723d0fb16ff0a02205f99caaaf1d39805840f6df85e1adbfcb23d26547191661c78fe619de8ac4313016952210250ca2af1e0f2552436c66078c24736e0396e36938c421fdbee6f4fe377e5f6af2103622f05e03b50037b7de99a00fe6cd1d0950fbb1e1f462cdff85ff077e57e931d2103cc3bf2cb38bfbf03264b567490ed45cd09f465843b0fc7a8dd1be9b852816e0053ae";
String expectedInput1Witness = "030047304402203bd2e54c5897addbd269928e713d8e2d54d87ee0c6d0aafb1d035a876090093f02205da2c24f85993a89bb75c4b92df6a849fa5d0cb915cce8d0c4b1889102e1eba7014751210250ca2af1e0f2552436c66078c24736e0396e36938c421fdbee6f4fe377e5f6af2103cc3bf2cb38bfbf03264b567490ed45cd09f465843b0fc7a8dd1be9b852816e0052ae";
Assertions.assertEquals(expectedInput0Witness, Hex.toHexString(psbt.getPsbtInputs().get(0).getFinalScriptWitness().toByteArray()));
Assertions.assertEquals(expectedInput1Witness, Hex.toHexString(psbt.getPsbtInputs().get(1).getFinalScriptWitness().toByteArray()));
String expectedTxHex = "02000000000102e21d3ab78e6237c1b292cb1445164efd65cc29a412faee98f9f09649eb96ce140000000000fdffffff24856cca96c4454b7084b3a6311caa23423a8b073de77b3e83f449d40f18bf7b0000000000fdffffff03fc310000000000002200206904cd6d7d5300792afd22c26767a7ceb1f28b062d606d9cdad891fdfc65c8fa950f0f0000000000220020b8ea72fcc41bc87d3d6789e36469e04198addf8f278c871bfa3d6b465fefa2aca1410f0000000000220020b1e82ca86dbf4e706a316691c2888e0ac1cfb7f5e65866fc671835d041af2dca04004730440220396b5523e3477e2991b6ff6d43c483211ef7dc27971967384a3f7f74be84dbf502201099ca19a5526cdff6557fa42a0def5b9168535c5855afc14c8388c28b7e1f45014730440220202f28a498aaa34e899fe2823a1a44c49d30bda759bacff335a723d0fb16ff0a02205f99caaaf1d39805840f6df85e1adbfcb23d26547191661c78fe619de8ac4313016952210250ca2af1e0f2552436c66078c24736e0396e36938c421fdbee6f4fe377e5f6af2103622f05e03b50037b7de99a00fe6cd1d0950fbb1e1f462cdff85ff077e57e931d2103cc3bf2cb38bfbf03264b567490ed45cd09f465843b0fc7a8dd1be9b852816e0053ae030047304402203bd2e54c5897addbd269928e713d8e2d54d87ee0c6d0aafb1d035a876090093f02205da2c24f85993a89bb75c4b92df6a849fa5d0cb915cce8d0c4b1889102e1eba7014751210250ca2af1e0f2552436c66078c24736e0396e36938c421fdbee6f4fe377e5f6af2103cc3bf2cb38bfbf03264b567490ed45cd09f465843b0fc7a8dd1be9b852816e0052aeb4000000";
Transaction tx = psbt.extractTransaction();
Assertions.assertEquals(expectedTxHex, Hex.toHexString(tx.bitcoinSerialize()));
}
@Test
public void finaliseExternalTaprootKeypathInput() {
ECKey spendPrivKey = ECKey.fromPrivate(Utils.hexToBytes("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"));
ECKey tweakKey = ECKey.fromPrivate(Utils.hexToBytes("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"));
byte[] tweak = Utils.bigIntegerToBytes(tweakKey.getPrivKey(), 32);
ECKey tweakedKey = spendPrivKey.addPrivate(tweakKey);
if(tweakedKey.hasOddYCoord()) {
tweakedKey = tweakedKey.negatePrivate();
}
byte[] outputXCoord = tweakedKey.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());
psbt.getPsbtInputs().getFirst().setSilentPaymentsTweak(tweak);
Assertions.assertTrue(psbt.getPsbtInputs().getFirst().signSilentPayments(spendPrivKey));
TransactionSignature schnorrSig = psbt.getPsbtInputs().getFirst().getTapKeyPathSignature();
Assertions.assertNotNull(schnorrSig);
Wallet wallet = new Wallet("test") {
@Override
public Policy getDefaultPolicy() {
return new Policy(new Miniscript("")) {
@Override
public int getNumSignaturesRequired() {
return 1;
}
};
}
@Override
public java.util.Map<PSBTInput, com.sparrowwallet.drongo.wallet.WalletNode> getSigningNodes(PSBT p) {
return java.util.Collections.emptyMap();
}
@Override
public java.util.Map<PSBTInput, com.sparrowwallet.drongo.wallet.WalletNode> getSigningNodes(PSBT p, boolean useDerivationFallback) {
return java.util.Collections.emptyMap();
}
};
wallet.finalise(psbt);
Assertions.assertTrue(psbt.isFinalized(), "Taproot keypath input should be finalised");
Assertions.assertEquals(0, psbt.getPsbtInputs().getFirst().getFinalScriptSig().getProgram().length);
TransactionWitness finalWitness = psbt.getPsbtInputs().getFirst().getFinalScriptWitness();
Assertions.assertEquals(1, finalWitness.getPushes().size(), "Taproot keypath witness must contain exactly one element (the schnorr signature)");
Assertions.assertArrayEquals(schnorrSig.encodeToBitcoin(), finalWitness.getPushes().get(0));
}
@Test
public void serializeRoundTrip() throws PSBTParseException {
String psbtStr1 = "cHNidP8BAJoCAAAAAljoeiG1ba8MI76OcHBFbDNvfLqlyHV5JPVFiHuyq911AAAAAAD/////g40EJ9DsZQpoqka7CwmK6kQiwHGyyng1Kgd5WdB86h0BAAAAAP////8CcKrwCAAAAAAWABTYXCtx0AYLCcmIauuBXlCZHdoSTQDh9QUAAAAAFgAUAK6pouXw+HaliN9VRuh0LR2HAI8AAAAAAAEAuwIAAAABqtc5MQGL0l+ErkALaISL4J23BurCrBgpi6vucatlb4sAAAAASEcwRAIgWPb8fGoz4bMVSNSByCbAFb0wE1qtQs1neQ2rZtKtJDsCIEoc7SYExnNbY5PltBaR3XiwDwxZQvufdRhW+qk4FX26Af7///8CgPD6AgAAAAAXqRQPuUY0IWlrgsgzryQceMF9295JNIfQ8gonAQAAABepFCnKdPigj4GZlCgYXJe12FLkBj9hh2UAAAABB9oARzBEAiB0AYrUGACXuHMyPAAVcgs2hMyBI4kQSOfbzZtVrWecmQIgc9Npt0Dj61Pc76M4I8gHBRTKVafdlUTxV8FnkTJhEYwBSDBFAiEA9hA4swjcHahlo0hSdG8BV3KTQgjG0kRUOTzZm98iF3cCIAVuZ1pnWm0KArhbFOXikHTYolqbV2C+ooFvZhkQoAbqAUdSIQKVg785rgpgl0etGZrd1jT6YQhVnWxc05tMIYPxq5bgfyEC2rYf9JoU22p9ArDNH7t4/EsYMStbTlTa5Nui+/71NtdSrgABASAAwusLAAAAABepFLf1+vQOPUClpFmx2zU18rcvqSHohwEHIyIAIIwjUxc3Q7WV37Sge3K6jkLjeX2nTof+fZ10l+OyAokDAQjaBABHMEQCIGLrelVhB6fHP0WsSrWh3d9vcHX7EnWWmn84Pv/3hLyyAiAMBdu3Rw2/LwhVfdNWxzJcHtMJE+mWzThAlF2xIijaXwFHMEQCIGX0W6WZi1mif/4ae+0BavHx+Q1Us6qPdFCqX1aiUQO9AiB/ckcDrR7blmgLKEtW1P/LiPf7dZ6rvgiqMPKbhROD0gFHUiEDCJ3BDHrG21T5EymvYXMz2ziM6tDCMfcjN50bmQMLAtwhAjrdkE89bc9Z3bkGsN7iNSm3/7ntUOXoYVGSaGAiHw5zUq4AIgIDqaTDf1mW06ol26xrVwrwZQOUSSlCRgs1R1Ptnuylh3EQ2QxqTwAAAIAAAACABAAAgAAiAgJ/Y5l1fS7/VaE2rQLGhLGDi2VW5fG2s0KCqUtrUAUQlhDZDGpPAAAAgAAAAIAFAACAAA==";
@ -1033,6 +1140,204 @@ public class PSBTTest {
Assertions.assertNull(input.getSilentPaymentsTweak());
}
@Test
public void sighashSingleLegacyMagicOneBugRefusedAtSignTime() {
ECKey victimKey = new ECKey();
P2PKHAddress victimAddr = new P2PKHAddress(victimKey.getPubKeyHash());
Script victimSpk = victimAddr.getOutputScript();
Transaction priorTx0 = new Transaction();
priorTx0.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
priorTx0.addOutput(100_000L, victimSpk);
Transaction priorTx1 = new Transaction();
priorTx1.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
priorTx1.addOutput(200_000L, victimSpk);
Transaction unsigned = new Transaction();
unsigned.addInput(priorTx0.getTxId(), 0, new Script(new byte[0]));
unsigned.addInput(priorTx1.getTxId(), 0, new Script(new byte[0]));
unsigned.addOutput(90_000L, victimSpk);
PSBT psbt = new PSBT(unsigned);
PSBTInput trapInput = psbt.getPsbtInputs().get(1);
trapInput.setNonWitnessUtxo(priorTx1);
trapInput.setSigHash(SigHash.SINGLE);
IllegalStateException thrown = Assertions.assertThrows(IllegalStateException.class, () -> trapInput.sign(victimKey));
Assertions.assertTrue(thrown.getMessage().contains("re-broadcastable"), "guard message should describe the risk");
Sha256Hash magicOne = Sha256Hash.wrap("0100000000000000000000000000000000000000000000000000000000000000");
Assertions.assertEquals(magicOne, unsigned.hashForLegacySignature(1, victimSpk, SigHash.SINGLE),
"underlying legacy hash function still returns the constant - guard is the sole defence at sign time");
}
@Test
public void verifySigHashesAcceptsSafeSighashes() throws PSBTSignatureException {
ECKey key = new ECKey();
Script spk = new P2PKHAddress(key.getPubKeyHash()).getOutputScript();
Transaction prior = new Transaction();
prior.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior.addOutput(100_000L, spk);
Transaction unsigned = new Transaction();
unsigned.addInput(prior.getTxId(), 0, new Script(new byte[0]));
unsigned.addOutput(90_000L, spk);
PSBT psbt = new PSBT(unsigned);
psbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior);
// unset sighash, ALL, and DEFAULT should all pass
psbt.verifySigHashes();
psbt.getPsbtInputs().get(0).setSigHash(SigHash.ALL);
psbt.verifySigHashes();
psbt.getPsbtInputs().get(0).setSigHash(SigHash.DEFAULT);
psbt.verifySigHashes();
}
@Test
public void verifySigHashesReportsMostSeriousIssue() {
ECKey key = new ECKey();
Script spk = new P2PKHAddress(key.getPubKeyHash()).getOutputScript();
Transaction prior0 = new Transaction();
prior0.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior0.addOutput(100_000L, spk);
Transaction prior1 = new Transaction();
prior1.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior1.addOutput(200_000L, spk);
Transaction unsigned = new Transaction();
unsigned.addInput(prior0.getTxId(), 0, new Script(new byte[0]));
unsigned.addInput(prior1.getTxId(), 0, new Script(new byte[0]));
unsigned.addOutput(45_000L, spk);
unsigned.addOutput(45_000L, spk);
PSBT psbt = new PSBT(unsigned);
psbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior0);
psbt.getPsbtInputs().get(0).setSigHash(SigHash.SINGLE); // severity 3
psbt.getPsbtInputs().get(1).setNonWitnessUtxo(prior1);
psbt.getPsbtInputs().get(1).setSigHash(SigHash.NONE); // severity 5
PSBTSignatureException ex = Assertions.assertThrows(PSBTSignatureException.class, psbt::verifySigHashes);
Assertions.assertTrue(ex.getMessage().contains("SIGHASH_NONE"), "should report the higher-severity NONE input");
Assertions.assertTrue(ex.getMessage().contains("Input 1"), "should identify the offending input index");
}
@Test
public void verifyCombinedSignaturesRejectsSigHashTampering() {
ECKey key = new ECKey();
Script spk = new P2PKHAddress(key.getPubKeyHash()).getOutputScript();
Transaction prior0 = new Transaction();
prior0.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior0.addOutput(100_000L, spk);
Transaction prior1 = new Transaction();
prior1.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior1.addOutput(200_000L, spk);
Transaction tx = new Transaction();
tx.addInput(prior0.getTxId(), 0, new Script(new byte[0]));
tx.addInput(prior1.getTxId(), 0, new Script(new byte[0]));
tx.addOutput(90_000L, spk);
PSBT localPsbt = new PSBT(tx);
localPsbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior0);
localPsbt.getPsbtInputs().get(0).setSigHash(SigHash.ALL);
localPsbt.getPsbtInputs().get(1).setNonWitnessUtxo(prior1);
localPsbt.getPsbtInputs().get(1).setSigHash(SigHash.ALL);
PSBT tamperedPsbt = new PSBT(tx);
tamperedPsbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior0);
tamperedPsbt.getPsbtInputs().get(0).setSigHash(SigHash.ALL);
tamperedPsbt.getPsbtInputs().get(1).setNonWitnessUtxo(prior1);
tamperedPsbt.getPsbtInputs().get(1).setSigHash(SigHash.SINGLE);
SigHash originalLocalSigHash = localPsbt.getPsbtInputs().get(1).getSigHash();
Assertions.assertEquals(SigHash.ALL, originalLocalSigHash);
PSBTSignatureException ex = Assertions.assertThrows(PSBTSignatureException.class,
() -> localPsbt.verifyCombinedSignatures(tamperedPsbt));
Assertions.assertTrue(ex.getMessage().contains("Combined PSBT would change sighash"),
"should describe the combine-time sighash change");
Assertions.assertTrue(ex.getMessage().contains("Input 1"),
"should identify which input the combine would tamper with");
Assertions.assertTrue(ex.getMessage().contains("SIGHASH_SINGLE"),
"should describe the unsafe sighash being introduced");
Assertions.assertEquals(originalLocalSigHash, localPsbt.getPsbtInputs().get(1).getSigHash(),
"local PSBT must be unchanged when verifyCombinedSignatures rejects the combine");
}
@Test
public void verifyCombinedSignaturesAcceptsUnchangedUnsafeSigHash() throws PSBTSignatureException {
ECKey key = new ECKey();
Script spk = new P2PKHAddress(key.getPubKeyHash()).getOutputScript();
Transaction prior = new Transaction();
prior.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior.addOutput(100_000L, spk);
Transaction tx = new Transaction();
tx.addInput(prior.getTxId(), 0, new Script(new byte[0]));
tx.addOutput(90_000L, spk);
PSBT localPsbt = new PSBT(tx);
localPsbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior);
localPsbt.getPsbtInputs().get(0).setSigHash(SigHash.SINGLE);
PSBT incomingPsbt = new PSBT(tx);
incomingPsbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior);
incomingPsbt.getPsbtInputs().get(0).setSigHash(SigHash.SINGLE);
localPsbt.verifyCombinedSignatures(incomingPsbt);
}
@Test
public void verifyCombinedSignaturesRejectsAdditiveUnsafeSigHash() {
ECKey key = new ECKey();
Script spk = new P2PKHAddress(key.getPubKeyHash()).getOutputScript();
Transaction prior = new Transaction();
prior.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior.addOutput(100_000L, spk);
Transaction tx = new Transaction();
tx.addInput(prior.getTxId(), 0, new Script(new byte[0]));
tx.addOutput(90_000L, spk);
PSBT localPsbt = new PSBT(tx);
localPsbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior);
localPsbt.getPsbtInputs().get(0).setSigHash(null);
PSBT incomingPsbt = new PSBT(tx);
incomingPsbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior);
incomingPsbt.getPsbtInputs().get(0).setSigHash(SigHash.NONE);
PSBTSignatureException ex = Assertions.assertThrows(PSBTSignatureException.class,
() -> localPsbt.verifyCombinedSignatures(incomingPsbt));
Assertions.assertTrue(ex.getMessage().contains("Combined PSBT would change sighash"));
Assertions.assertTrue(ex.getMessage().contains("Input 0"));
Assertions.assertTrue(ex.getMessage().contains("SIGHASH_NONE"));
}
@Test
public void verifySigHashesRejectsPlainSighashSingle() {
ECKey key = new ECKey();
Script spk = new P2PKHAddress(key.getPubKeyHash()).getOutputScript();
Transaction prior = new Transaction();
prior.addInput(Sha256Hash.ZERO_HASH, 0L, new Script(new byte[0]));
prior.addOutput(100_000L, spk);
Transaction unsigned = new Transaction();
unsigned.addInput(prior.getTxId(), 0, new Script(new byte[0]));
unsigned.addOutput(45_000L, spk);
unsigned.addOutput(45_000L, spk);
PSBT psbt = new PSBT(unsigned);
psbt.getPsbtInputs().get(0).setNonWitnessUtxo(prior);
psbt.getPsbtInputs().get(0).setSigHash(SigHash.SINGLE);
PSBTSignatureException ex = Assertions.assertThrows(PSBTSignatureException.class, psbt::verifySigHashes);
Assertions.assertTrue(ex.getMessage().contains("SIGHASH_SINGLE"));
}
@AfterEach
public void tearDown() {
Network.set(null);

View File

@ -1,5 +1,6 @@
package com.sparrowwallet.drongo.silentpayments;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.P2TRAddress;
@ -7,6 +8,8 @@ import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.*;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@ -14,6 +17,7 @@ import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.SecureRandom;
import java.util.*;
public class SilentPaymentUtilsTest {
@ -991,4 +995,194 @@ public class SilentPaymentUtilsTest {
Assertions.assertTrue(matches.isEmpty());
}
private static final String VERIFY_TEST_SEED = "absent essay fox snake vast pumpkin height crouch silent bulb excuse razor";
private static final String VERIFY_TEST_SP_ADDRESS = "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv";
private static final long VERIFY_INPUT_VALUE = 100_000L;
private static final long VERIFY_OUTPUT_VALUE = 50_000L;
@Test
public void testVerifyValidPsbt() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
Assertions.assertEquals(1, signingNodes.size(), "Wallet should recognize its own input");
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertEquals(1, verified.size(), "A legitimately constructed SP PSBT must verify");
Assertions.assertEquals(SilentPaymentAddress.from(VERIFY_TEST_SP_ADDRESS), verified.values().iterator().next());
}
@Test
public void testVerifyRejectsNoProof() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
byte[] arbitraryXOnly = new byte[32];
new SecureRandom().nextBytes(arbitraryXOnly);
psbt.getPsbtOutputs().get(0).setScript(ScriptType.P2TR.getOutputScript(arbitraryXOnly));
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertTrue(verified.isEmpty(), "Poisoned PSBT without BIP-375 metadata must be rejected");
}
@Test
public void testVerifyRejectsForgedShare() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
Map.Entry<ECKey, ECKey> shareEntry = psbt.getSilentPaymentsEcdhShares().entrySet().iterator().next();
ECKey forged = ECKey.fromPublicOnly(ECKey.fromPrivate(Sha256Hash.hash("forged".getBytes())).getPubKey());
psbt.getSilentPaymentsEcdhShares().put(shareEntry.getKey(), forged);
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertTrue(verified.isEmpty(), "Forged ECDH share must fail DLEQ verification");
}
@Test
public void testVerifyRejectsMutatedScript() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
byte[] mutatedXOnly = new byte[32];
new SecureRandom().nextBytes(mutatedXOnly);
psbt.getPsbtOutputs().get(0).setScript(ScriptType.P2TR.getOutputScript(mutatedXOnly));
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertTrue(verified.isEmpty(), "Output script that doesn't derive from the claimed SP address must be rejected");
}
@Test
public void testVerifyAbortsMidSign() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertTrue(verified.isEmpty(), "Mid-sign PSBT must abort all-or-nothing without persisting");
}
@Test
public void testVerifyRejectsForeignInput() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
byte[] foreignXOnly = new byte[32];
new SecureRandom().nextBytes(foreignXOnly);
Script foreignScript = ScriptType.P2TR.getOutputScript(foreignXOnly);
psbt.getPsbtInputs().get(0).setWitnessUtxo(new TransactionOutput(null, VERIFY_INPUT_VALUE, foreignScript));
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertTrue(verified.isEmpty(), "PSBT whose inputs aren't from this wallet must be rejected");
}
@Test
public void testVerifyEmptyPsbt() throws Exception {
Wallet wallet = buildVerifyWallet();
primeReceiveNode(wallet, 0);
Transaction tx = new Transaction();
tx.setVersion(2);
PSBT psbt = new PSBT(tx);
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertTrue(verified.isEmpty(), "PSBT with no SP outputs returns empty by definition");
}
@Test
public void testVerifyRejectsMissingProof() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
psbt.getSilentPaymentsDLEQProofs().clear();
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
Assertions.assertTrue(verified.isEmpty(), "Missing DLEQ proof must cause rejection");
}
@Test
public void testComputeRejectsInjectedSpInfo() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
byte[] baitXOnly = new byte[32];
new SecureRandom().nextBytes(baitXOnly);
Script baitScript = ScriptType.P2TR.getOutputScript(baitXOnly);
psbt.getPsbtOutputs().getFirst().setScript(baitScript);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
Assertions.assertThrows(InvalidSilentPaymentException.class, () -> wallet.computeSilentPaymentOutputs(psbt, signingNodes),
"Injected PSBT_OUT_SP_V0_INFO without valid BIP-375 proofs must abort signing");
Assertions.assertArrayEquals(baitScript.getProgram(), psbt.getPsbtOutputs().get(0).getScript().getProgram(),
"Visible output script must not be mutated when SP metadata fails verification");
}
@Test
public void testComputePreservesScriptForVerifiedPsbt() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
byte[] computedScript = psbt.getPsbtOutputs().getFirst().getScript().getProgram();
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
Assertions.assertArrayEquals(computedScript, psbt.getPsbtOutputs().getFirst().getScript().getProgram(),
"Already-computed and proof-backed SP output script must be preserved by a subsequent compute call");
}
private static Wallet buildVerifyWallet() throws Exception {
DeterministicSeed seed = new DeterministicSeed(VERIFY_TEST_SEED, "", 0, DeterministicSeed.Type.BIP39);
Wallet wallet = new Wallet();
wallet.setPolicyType(PolicyType.SINGLE_HD);
wallet.setScriptType(ScriptType.P2WPKH);
Keystore keystore = Keystore.fromSeed(seed, PolicyType.SINGLE_HD, ScriptType.P2WPKH.getDefaultDerivation());
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, ScriptType.P2WPKH, wallet.getKeystores(), 1));
return wallet;
}
private static WalletNode primeReceiveNode(Wallet wallet, int index) {
WalletNode node = new WalletNode(wallet, KeyPurpose.RECEIVE, index);
TreeSet<WalletNode> children = new TreeSet<>();
children.add(node);
wallet.getNode(KeyPurpose.RECEIVE).setChildren(children);
return node;
}
private static PSBT buildVerifySendingPsbt(WalletNode receiveNode) {
Script inputScript = receiveNode.getOutputScript();
Transaction tx = new Transaction();
tx.setVersion(2);
tx.addInput(Sha256Hash.wrap("0000000000000000000000000000000000000000000000000000000000000001"), 0, new Script(new byte[0]));
tx.addOutput(VERIFY_OUTPUT_VALUE, new Script(new byte[0]));
PSBT psbt = new PSBT(tx);
psbt.getPsbtInputs().get(0).setWitnessUtxo(new TransactionOutput(null, VERIFY_INPUT_VALUE, inputScript));
psbt.getPsbtOutputs().get(0).setSilentPaymentAddress(SilentPaymentAddress.from(VERIFY_TEST_SP_ADDRESS));
return psbt;
}
}

View File

@ -3,9 +3,13 @@ package com.sparrowwallet.drongo.uri;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
public class BitcoinUriTest {
private static final String ADDRESS = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
@Test
public void testSamourai() throws BitcoinURIParseException {
String uri = "bitcoin:BC1QT4NRM47695YWDG9N30N68JARMXRJNKFMR36994?amount=0,001";
@ -14,4 +18,34 @@ public class BitcoinUriTest {
Assertions.assertEquals("BC1QT4NRM47695YWDG9N30N68JARMXRJNKFMR36994".toLowerCase(Locale.ROOT), bitcoinURI.getAddress().toString());
Assertions.assertEquals(Long.valueOf(100000), bitcoinURI.getAmount());
}
@Test
public void acceptsHttpsPayjoinUrl() throws BitcoinURIParseException {
BitcoinURI bitcoinURI = payjoinUri("https://example.com/payjoin");
Assertions.assertNotNull(bitcoinURI.getPayjoinUrl());
Assertions.assertEquals("https://example.com/payjoin", bitcoinURI.getPayjoinUrl().toString());
}
@Test
public void acceptsHttpOnionPayjoinUrl() throws BitcoinURIParseException {
BitcoinURI bitcoinURI = payjoinUri("http://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/payjoin");
Assertions.assertNotNull(bitcoinURI.getPayjoinUrl());
Assertions.assertEquals("http://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/payjoin", bitcoinURI.getPayjoinUrl().toString());
}
@Test
public void rejectsNonHttpOnionPayjoinUrl() throws BitcoinURIParseException {
BitcoinURI bitcoinURI = payjoinUri("file://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/payjoin");
Assertions.assertNull(bitcoinURI.getPayjoinUrl());
}
@Test
public void rejectsMalformedPayjoinUrl() throws BitcoinURIParseException {
BitcoinURI bitcoinURI = payjoinUri("payjoin");
Assertions.assertNull(bitcoinURI.getPayjoinUrl());
}
private static BitcoinURI payjoinUri(String payjoinUrl) throws BitcoinURIParseException {
return new BitcoinURI("bitcoin:" + ADDRESS + "?pj=" + URLEncoder.encode(payjoinUrl, StandardCharsets.UTF_8));
}
}