From e12fdfa47c406b74f496bada0c8734b51d8f8291 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 19 Aug 2025 15:22:00 +0200 Subject: [PATCH] add initial silent payments library support --- .../sparrowwallet/drongo/KeyDerivation.java | 4 + .../sparrowwallet/drongo/protocol/Bech32.java | 5 + .../silentpayments/SilentPaymentAddress.java | 63 +++++++++ .../SilentPaymentScanAddress.java | 36 +++++ .../silentpayments/SilentPaymentUtils.java | 132 ++++++++++++++++++ src/main/java/module-info.java | 1 + .../SilentPaymentAddressTest.java | 18 +++ .../SilentPaymentScanAddressTest.java | 44 ++++++ .../SilentPaymentUtilsTest.java | 62 ++++++++ 9 files changed, 365 insertions(+) create mode 100644 src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddress.java create mode 100644 src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java create mode 100644 src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java create mode 100644 src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddressTest.java create mode 100644 src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java create mode 100644 src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtilsTest.java diff --git a/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java b/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java index e5d8649..c9f3423 100644 --- a/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java +++ b/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java @@ -104,6 +104,10 @@ public class KeyDerivation { return List.of(new ChildNumber(47, true), new ChildNumber(Network.get() == Network.MAINNET ? 0 : 1, true), new ChildNumber(Math.max(0, account), true)); } + public static List getBip352Derivation(int account) { + return List.of(new ChildNumber(352, true), new ChildNumber(Network.get() == Network.MAINNET ? 0 : 1, true), new ChildNumber(Math.max(0, account), true)); + } + public KeyDerivation copy() { return new KeyDerivation(masterFingerprint, derivationPath); } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java b/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java index b625fec..6f756ca 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java @@ -121,6 +121,11 @@ public class Bech32 { /** Encode a Bech32 string. */ public static String encode(String hrp, int version, final byte[] values) { Encoding encoding = (version == 0 ? Encoding.BECH32 : Encoding.BECH32M); + return encode(hrp, version, encoding, values); + } + + /** Encode a Bech32 string. */ + public static String encode(String hrp, int version, Encoding encoding, final byte[] values) { return encode(hrp, encoding, encode(version, values)); } diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddress.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddress.java new file mode 100644 index 0000000..0ab49ab --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddress.java @@ -0,0 +1,63 @@ +package com.sparrowwallet.drongo.silentpayments; + +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.Bech32; + +import java.util.Arrays; + +public class SilentPaymentAddress { + private final ECKey scanAddress; + private final ECKey spendAddress; + + public SilentPaymentAddress(ECKey scanAddress, ECKey spendAddress) { + this.scanAddress = scanAddress; + this.spendAddress = spendAddress; + } + + public ECKey getScanKey() { + return scanAddress; + } + + public ECKey getSpendKey() { + return spendAddress; + } + + public String getAddress() { + byte[] keys = Utils.concat(scanAddress.getPubKey(), spendAddress.getPubKey()); + return Bech32.encode(getHrp(), 0, Bech32.Encoding.BECH32M, keys); + } + + private static String getHrp() { + return Network.get() == Network.MAINNET ? "sp" : "tsp"; + } + + public static SilentPaymentAddress from(String address) { + Bech32.Bech32Data data = Bech32.decode(address, 1023); + if(data.encoding != Bech32.Encoding.BECH32M) { + throw new IllegalArgumentException("Invalid silent payments address encoding"); + } + + if(!getHrp().equals(data.hrp)) { + throw new IllegalArgumentException("Invalid silent payments address hrp"); + } + + int witnessVersion = data.data[0]; + if(witnessVersion != 0) { + throw new UnsupportedOperationException("Unsupported silent payments address witness version"); + } + + byte[] convertedProgram = Arrays.copyOfRange(data.data, 1, data.data.length); + byte[] witnessProgram = Bech32.convertBits(convertedProgram, 0, convertedProgram.length, 5, 8, false); + + if(witnessProgram.length != 66) { + throw new IllegalArgumentException("Invalid silent payments address witness length"); + } + + ECKey scanPubKey = ECKey.fromPublicOnly(Arrays.copyOfRange(witnessProgram, 0, 33)); + ECKey spendPubKey = ECKey.fromPublicOnly(Arrays.copyOfRange(witnessProgram, 33, 66)); + + return new SilentPaymentAddress(scanPubKey, spendPubKey); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java new file mode 100644 index 0000000..eec0d65 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java @@ -0,0 +1,36 @@ +package com.sparrowwallet.drongo.silentpayments; + +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.policy.Policy; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.*; + +public class SilentPaymentScanAddress extends SilentPaymentAddress { + public SilentPaymentScanAddress(ECKey scanPrivateKey, ECKey spendPublicKey) { + super(scanPrivateKey, spendPublicKey); + + if(scanPrivateKey.isPubKeyOnly()) { + throw new IllegalArgumentException("Scan key must be a private key"); + } + } + + public static SilentPaymentScanAddress from(DeterministicSeed deterministicSeed, int account) throws MnemonicException { + Wallet spWallet = new Wallet(); + spWallet.setPolicyType(PolicyType.SINGLE); + spWallet.setScriptType(ScriptType.P2WPKH); + Keystore spKeystore = Keystore.fromSeed(deterministicSeed, KeyDerivation.getBip352Derivation(account)); + spWallet.getKeystores().add(spKeystore); + spWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, spWallet.getKeystores(), 1)); + + WalletNode spendNode = new WalletNode(spWallet, "m/0'/0"); + WalletNode scanNode = new WalletNode(spWallet, "m/1'/0"); + + return from(spKeystore.getKey(scanNode), ECKey.fromPublicOnly(spKeystore.getKey(spendNode))); + } + + public static SilentPaymentScanAddress from(ECKey scanPrivateKey, ECKey spendPublicKey) { + return new SilentPaymentScanAddress(scanPrivateKey, spendPublicKey); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java new file mode 100644 index 0000000..741c8ac --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java @@ -0,0 +1,132 @@ +package com.sparrowwallet.drongo.silentpayments; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.*; +import org.bitcoin.NativeSecp256k1; +import org.bitcoin.NativeSecp256k1Util; +import org.bitcoin.Secp256k1Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class SilentPaymentUtils { + private static final Logger log = LoggerFactory.getLogger(SilentPaymentUtils.class); + + private static final List SCRIPT_TYPES = List.of(ScriptType.P2TR, ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH); + + public static boolean isEligible(Transaction tx, Map spentOutputs) { + if(!containsTaprootOutput(tx)) { + return false; + } + + if(getInputPubKeys(tx, spentOutputs).isEmpty()) { + return false; + } + + if(spendsInvalidSegwitOutput(tx, spentOutputs)) { + return false; + } + + return true; + } + + public static List getInputPubKeys(Transaction tx, Map spentOutputs) { + List keys = new ArrayList<>(); + for(TransactionInput input : tx.getInputs()) { + HashIndex hashIndex = new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex()); + TransactionOutput output = spentOutputs.get(hashIndex); + if(output == null) { + throw new IllegalStateException("No output found for input " + input.getOutpoint()); + } + for(ScriptType scriptType : SCRIPT_TYPES) { + if(scriptType.isScriptType(output.getScript())) { + switch(scriptType) { + case P2TR: + keys.add(output.getScript().getPubKey()); + break; + case P2WPKH: + case P2SH_P2WPKH: + if(input.getWitness() != null && input.getWitness().getPushCount() == 2) { + keys.add(ECKey.fromPublicOnly(input.getWitness().getPushes().get(input.getWitness().getPushCount() - 1))); + } + break; + case P2PKH: + for(ScriptChunk scriptChunk : input.getScriptSig().getChunks()) { + if(scriptChunk.isPubKey()) { + keys.add(scriptChunk.getPubKey()); + } + } + break; + default: + throw new IllegalStateException("Unhandled script type " + scriptType); + } + } + } + } + + return keys; + } + + public static boolean containsTaprootOutput(Transaction tx) { + for(TransactionOutput output : tx.getOutputs()) { + if(ScriptType.P2TR.isScriptType(output.getScript())) { + return true; + } + } + + return false; + } + + public static boolean spendsInvalidSegwitOutput(Transaction tx, Map spentOutputs) { + for(TransactionInput input : tx.getInputs()) { + HashIndex hashIndex = new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex()); + Script scriptPubKey = spentOutputs.get(hashIndex).getScript(); + List chunks = scriptPubKey.getChunks(); + if(chunks.size() == 2 && chunks.getFirst().isOpCode() && chunks.get(1).getData() != null + && chunks.getFirst().getOpcode() >= ScriptOpCodes.OP_2 && chunks.getFirst().getOpcode() <= ScriptOpCodes.OP_16) { + return true; + } + } + + return false; + } + + public static byte[] getTweak(Transaction tx, Map spentOutputs) { + if(tx.getOutputs().stream().noneMatch(output -> ScriptType.P2TR.isScriptType(output.getScript()))) { + return null; + } + + if(spendsInvalidSegwitOutput(tx, spentOutputs)) { + return null; + } + + List inputKeys = getInputPubKeys(tx, spentOutputs); + if(inputKeys.isEmpty()) { + return null; + } + + if(!Secp256k1Context.isEnabled()) { + throw new IllegalStateException("libsecp256k1 is not enabled"); + } + + try { + byte[][] inputPubKeys = new byte[inputKeys.size()][]; + for(int i = 0; i < inputPubKeys.length; i++) { + inputPubKeys[i] = inputKeys.get(i).getPubKey(true); + } + byte[] combinedPubKey = NativeSecp256k1.pubKeyCombine(inputPubKeys, true); + byte[] smallestOutpoint = tx.getInputs().stream().map(input -> input.getOutpoint().bitcoinSerialize()).min(new Utils.LexicographicByteArrayComparator()).orElseThrow(); + + byte[] inputHash = Utils.taggedHash("BIP0352/Inputs", Utils.concat(smallestOutpoint, combinedPubKey)); + return NativeSecp256k1.pubKeyTweakMul(combinedPubKey, inputHash, true); + } catch(NativeSecp256k1Util.AssertFailException e) { + log.error("Error computing tweak", e); + } + + return null; + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 1447377..1fabb6c 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -21,5 +21,6 @@ open module com.sparrowwallet.drongo { exports com.sparrowwallet.drongo.bip47; exports com.sparrowwallet.drongo.dns; exports com.sparrowwallet.drongo.wallet.slip39; + exports com.sparrowwallet.drongo.silentpayments; exports org.bitcoin; } \ No newline at end of file diff --git a/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddressTest.java b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddressTest.java new file mode 100644 index 0000000..4c024a1 --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentAddressTest.java @@ -0,0 +1,18 @@ +package com.sparrowwallet.drongo.silentpayments; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.crypto.ECKey; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class SilentPaymentAddressTest { + @Test + public void testDecode() { + ECKey scanPrivateKey = ECKey.fromPrivate(Utils.hexToBytes("0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c")); + ECKey spendPrivateKey = ECKey.fromPrivate(Utils.hexToBytes("9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3")); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + Assertions.assertEquals(ECKey.fromPublicOnly(scanPrivateKey), silentPaymentAddress.getScanKey()); + Assertions.assertEquals(ECKey.fromPublicOnly(spendPrivateKey), silentPaymentAddress.getSpendKey()); + } +} diff --git a/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java new file mode 100644 index 0000000..6286382 --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java @@ -0,0 +1,44 @@ +package com.sparrowwallet.drongo.silentpayments; + +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.wallet.DeterministicSeed; +import com.sparrowwallet.drongo.wallet.MnemonicException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class SilentPaymentScanAddressTest { + @Test + public void testEncode() { + ECKey scanPrivateKey = ECKey.fromPrivate(Utils.hexToBytes("0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c")); + ECKey spendPrivateKey = ECKey.fromPrivate(Utils.hexToBytes("9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3")); + + SilentPaymentScanAddress silentPaymentScanAddress = SilentPaymentScanAddress.from(scanPrivateKey, ECKey.fromPublicOnly(spendPrivateKey)); + Assertions.assertEquals("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", silentPaymentScanAddress.getAddress()); + } + + @Test + public void testEncodeFromSeed() throws MnemonicException { + Network.set(Network.TESTNET); + DeterministicSeed seed = new DeterministicSeed("life life life life life life life life life life life life", "", 0, DeterministicSeed.Type.BIP39); + SilentPaymentScanAddress silentPaymentScanAddress = SilentPaymentScanAddress.from(seed, 0); + Assertions.assertEquals("tsp1qq0grgkzt7uwfst33pyge7k9mrkag0r9vrklc695n0pw7kwwc7qddqqley3n2a6z8q7vhkhzedtzj5kr86hv6fhh0zvu2j9tjrrxa4ye3acuv6f3q", silentPaymentScanAddress.getAddress()); + Assertions.assertEquals("36dc57ced5f4a76059947802f094ea40d0c11c74d444a1e7d3ea5e74b8d83d45", Utils.bytesToHex(silentPaymentScanAddress.getScanKey().getPrivKeyBytes())); + Assertions.assertEquals("03f92466aee84707997b5c596ac52a5867d5d9a4deef1338a9157218cdda9331ee", Utils.bytesToHex(silentPaymentScanAddress.getSpendKey().getPubKey())); + } + + @Test + public void testEncodeFromSeed2() throws MnemonicException { + Network.set(Network.TESTNET); + DeterministicSeed seed = new DeterministicSeed("resist cube wrap sleep catalog shadow door scale stage rail script observe", "", 0, DeterministicSeed.Type.BIP39); + SilentPaymentScanAddress silentPaymentScanAddress = SilentPaymentScanAddress.from(seed, 0); + Assertions.assertEquals("tsp1qqgksl44sjwjkedsmrfmf2xqsnyt2njtjp5plk2kzjlnd9el2n76awqe5j974lvkf2utv7nrg0eaug55z86n6n3v4e9alnftdzgqk6pqmm5dphvxn", silentPaymentScanAddress.getAddress()); + } + + @AfterEach + public void tearDown() { + Network.set(null); + } +} diff --git a/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtilsTest.java b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtilsTest.java new file mode 100644 index 0000000..285329a --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtilsTest.java @@ -0,0 +1,62 @@ +package com.sparrowwallet.drongo.silentpayments; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +public class SilentPaymentUtilsTest { + @Test + public void testTweak() { + Transaction transaction = new Transaction(); + transaction.addInput(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, new Script(Utils.hexToBytes("483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5"))); + transaction.addInput(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, new Script(Utils.hexToBytes("48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792"))); + transaction.addOutput(1000L, ScriptType.P2TR.getOutputScript(ECKey.fromPublicOnly(Utils.hexToBytes("3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1")))); + + Map spentOutputs = new HashMap<>(); + HashIndex hashIndex0 = new HashIndex(transaction.getInputs().getFirst().getOutpoint().getHash(), transaction.getInputs().getFirst().getOutpoint().getIndex()); + TransactionOutput spentOutput0 = new TransactionOutput(new Transaction(), 400L, new Script(Utils.hexToBytes("76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac"))); + HashIndex hashIndex1 = new HashIndex(transaction.getInputs().getLast().getOutpoint().getHash(), transaction.getInputs().getLast().getOutpoint().getIndex()); + TransactionOutput spentOutput1 = new TransactionOutput(new Transaction(), 400L, new Script(Utils.hexToBytes("76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac"))); + spentOutputs.put(hashIndex0, spentOutput0); + spentOutputs.put(hashIndex1, spentOutput1); + + byte[] tweak = SilentPaymentUtils.getTweak(transaction, spentOutputs); + Assertions.assertNotNull(tweak); + Assertions.assertEquals("024ac253c216532e961988e2a8ce266a447c894c781e52ef6cee902361db960004", Utils.bytesToHex(tweak)); + } + + @Test + public void testTweakReversed() { + Transaction transaction = new Transaction(); + transaction.addInput(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, new Script(Utils.hexToBytes("483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5"))); + transaction.addInput(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, new Script(Utils.hexToBytes("48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792"))); + transaction.addOutput(1000L, ScriptType.P2TR.getOutputScript(ECKey.fromPublicOnly(Utils.hexToBytes("3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1")))); + + Map spentOutputs = new HashMap<>(); + HashIndex hashIndex0 = new HashIndex(transaction.getInputs().getFirst().getOutpoint().getHash(), transaction.getInputs().getFirst().getOutpoint().getIndex()); + TransactionOutput spentOutput0 = new TransactionOutput(new Transaction(), 400L, new Script(Utils.hexToBytes("76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac"))); + HashIndex hashIndex1 = new HashIndex(transaction.getInputs().getLast().getOutpoint().getHash(), transaction.getInputs().getLast().getOutpoint().getIndex()); + TransactionOutput spentOutput1 = new TransactionOutput(new Transaction(), 400L, new Script(Utils.hexToBytes("76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac"))); + spentOutputs.put(hashIndex0, spentOutput0); + spentOutputs.put(hashIndex1, spentOutput1); + + byte[] tweak = SilentPaymentUtils.getTweak(transaction, spentOutputs); + Assertions.assertNotNull(tweak); + Assertions.assertEquals("024ac253c216532e961988e2a8ce266a447c894c781e52ef6cee902361db960004", Utils.bytesToHex(tweak)); + } + + @Test + public void testInvalidOutput() { + Transaction transaction = new Transaction(); + transaction.addInput(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, new Script(Utils.hexToBytes("483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5"))); + transaction.addInput(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, new Script(Utils.hexToBytes("48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792"))); + transaction.addOutput(1000L, ScriptType.P2WPKH.getOutputScript(ECKey.fromPublicOnly(Utils.hexToBytes("3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1")))); + + Assertions.assertFalse(SilentPaymentUtils.containsTaprootOutput(transaction)); + } +}