diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java index 4c9cb0f..72c4ef7 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java @@ -328,14 +328,43 @@ public class ECKey { /** Multiply the public point by the provided private key */ public ECKey multiply(BigInteger privKey) { + return multiply(privKey, false); + } + + /** Multiply the public point by the provided private key */ + public ECKey multiply(BigInteger privKey, boolean compressed) { ECPoint point = pub.get().multiply(privKey); - return ECKey.fromPublicOnly(point, false); + return ECKey.fromPublicOnly(point, compressed); } /** Add to the public point by the provided public key */ public ECKey add(ECKey pubKey) { + return add(pubKey, false); + } + + /** Add to the public point by the provided public key */ + public ECKey add(ECKey pubKey, boolean compressed) { ECPoint point = pub.get().add(pubKey.getPubKeyPoint()); - return ECKey.fromPublicOnly(point, false); + return ECKey.fromPublicOnly(point, compressed); + } + + /** Add to the private key by the provided private key using modular arithmetic */ + public ECKey addPrivate(ECKey privKey) { + if(this.priv == null || privKey.priv == null) { + throw new IllegalStateException("Key did not contain a private key"); + } + + return ECKey.fromPrivate(this.priv.add(privKey.priv).mod(CURVE.getN()), true); + } + + /** Negate the provided private key */ + public ECKey negate() { + if(priv == null) { + throw new IllegalStateException("Key did not contain a private key"); + } + + BigInteger negatedPrivKey = CURVE.getN().subtract(priv); + return ECKey.fromPrivate(negatedPrivKey, isCompressed()); } /** Calculate the value of the public key point modulo the secp256k1 curve order */ diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPayment.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPayment.java new file mode 100644 index 0000000..898451e --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPayment.java @@ -0,0 +1,28 @@ +package com.sparrowwallet.drongo.silentpayments; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.P2TRAddress; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Payment; + +import java.util.Set; + +public class SilentPayment extends Payment { + public static final Set VALID_INPUT_SCRIPT_TYPES = Set.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH, ScriptType.P2TR); + + private final SilentPaymentAddress silentPaymentAddress; + + public SilentPayment(SilentPaymentAddress silentPaymentAddress, String label, long amount, boolean sendMax) { + super(getDummyAddress(), label, amount, sendMax); + this.silentPaymentAddress = silentPaymentAddress; + } + + public static Address getDummyAddress() { + return new P2TRAddress(Utils.hexToBytes("0000000000000000000000000000000000000000000000000000000000000000")); + } + + public SilentPaymentAddress getSilentPaymentAddress() { + return silentPaymentAddress; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java index eec0d65..4c92cb1 100644 --- a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddress.java @@ -16,6 +16,15 @@ public class SilentPaymentScanAddress extends SilentPaymentAddress { } } + public SilentPaymentScanAddress getChangeAddress() { + return getLabelledAddress(0); + } + + public SilentPaymentScanAddress getLabelledAddress(int labelIndex) { + ECKey labelledSpendKey = SilentPaymentUtils.getLabelledSpendKey(getScanKey(), getSpendKey(), labelIndex); + return new SilentPaymentScanAddress(getScanKey(), labelledSpendKey); + } + public static SilentPaymentScanAddress from(DeterministicSeed deterministicSeed, int account) throws MnemonicException { Wallet spWallet = new Wallet(); spWallet.setPolicyType(PolicyType.SINGLE); diff --git a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java index aa4322a..8b67f65 100644 --- a/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java +++ b/src/main/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtils.java @@ -3,16 +3,21 @@ package com.sparrowwallet.drongo.silentpayments; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.MnemonicException; +import com.sparrowwallet.drongo.wallet.WalletNode; 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.Arrays; -import java.util.List; -import java.util.Map; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.*; + +import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR; public class SilentPaymentUtils { private static final Logger log = LoggerFactory.getLogger(SilentPaymentUtils.class); @@ -26,6 +31,10 @@ public class SilentPaymentUtils { (byte) 0x47, (byte) 0xbf, (byte) 0xee, (byte) 0x9a, (byte) 0xce, (byte) 0x80, (byte) 0x3a, (byte) 0xc0 }; + public static final String BIP_0352_INPUTS_TAG = "BIP0352/Inputs"; + public static final String BIP_0352_SHARED_SECRET_TAG = "BIP0352/SharedSecret"; + public static final String BIP_0352_LABEL_TAG = "BIP0352/Label"; + public static boolean isEligible(Transaction tx, Map spentScriptPubKeys) { if(!containsTaprootOutput(tx)) { return false; @@ -169,7 +178,7 @@ public class SilentPaymentUtils { 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)); + byte[] inputHash = Utils.taggedHash(BIP_0352_INPUTS_TAG, Utils.concat(smallestOutpoint, combinedPubKey)); return NativeSecp256k1.pubKeyTweakMul(combinedPubKey, inputHash, true); } catch(NativeSecp256k1Util.AssertFailException e) { log.error("Error computing tweak", e); @@ -177,4 +186,93 @@ public class SilentPaymentUtils { return null; } + + public static void updateSilentPayments(List silentPayments, Map utxos) { + ECKey summedPrivateKey = getSummedPrivateKey(utxos.values()); + BigInteger inputHash = getInputHash(utxos.keySet(), summedPrivateKey); + Map> scanKeyGroups = getScanKeyGroups(silentPayments); + for(Map.Entry> scanKeyGroup : scanKeyGroups.entrySet()) { + ECKey scanKey = scanKeyGroup.getKey(); + ECKey sharedSecret = scanKey.multiply(inputHash).multiply(summedPrivateKey.getPrivKey(), true); + int k = 0; + for(SilentPayment silentPayment : scanKeyGroup.getValue()) { + BigInteger tk = new BigInteger(1, Utils.taggedHash(BIP_0352_SHARED_SECRET_TAG, + Utils.concat(sharedSecret.getPubKey(), ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(k).array()))); + if(tk.equals(BigInteger.ZERO) || tk.compareTo(ECKey.CURVE.getCurve().getOrder()) >= 0) { + throw new IllegalArgumentException("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); + silentPayment.setAddress(P2TR.getAddress(pkm.getPubKeyXCoord())); + k++; + } + } + } + + public static Map> getScanKeyGroups(Collection silentPayments) { + Map> scanKeyGroups = new LinkedHashMap<>(); + for(SilentPayment silentPayment : silentPayments) { + SilentPaymentAddress address = silentPayment.getSilentPaymentAddress(); + List scanKeyGroup = scanKeyGroups.computeIfAbsent(address.getScanKey(), _ -> new ArrayList<>()); + scanKeyGroup.add(silentPayment); + } + + return scanKeyGroups; + } + + public static BigInteger getInputHash(Set outpoints, ECKey summedPrivateKey) { + byte[] smallestOutpoint = getSmallestOutpoint(outpoints); + byte[] concat = Utils.concat(smallestOutpoint, summedPrivateKey.getPubKey()); + 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 IllegalArgumentException("The input hash is invalid for the eligible silent payments inputs"); + } + + return inputHash; + } + + public static ECKey getSummedPrivateKey(Collection walletNodes) { + ECKey summedPrivateKey = null; + for(WalletNode walletNode : walletNodes) { + if(!walletNode.getWallet().canSendSilentPayments()) { + continue; + } + + try { + ECKey privateKey = walletNode.getWallet().getKeystores().getFirst().getKey(walletNode); + if(walletNode.getWallet().getScriptType() == P2TR && !privateKey.getPubKeyPoint().getYCoord().toBigInteger().mod(BigInteger.TWO).equals(BigInteger.ZERO)) { + privateKey = privateKey.negate(); + } + if(summedPrivateKey == null) { + summedPrivateKey = privateKey; + } else { + summedPrivateKey = summedPrivateKey.addPrivate(privateKey); + } + } catch(MnemonicException e) { + throw new IllegalArgumentException("Invalid wallet mnemonic for sending silent payment", e); + } + } + + if(summedPrivateKey == null) { + throw new IllegalArgumentException("There are no eligible inputs to derive a silent payments shared secret"); + } + + if(summedPrivateKey.getPrivKey().equals(BigInteger.ZERO)) { + throw new IllegalArgumentException("The summed private key is zero for the eligible silent payments inputs"); + } + + return summedPrivateKey; + } + + public static byte[] getSmallestOutpoint(Set outpoints) { + return outpoints.stream().map(outpoint -> new TransactionOutPoint(outpoint.getHash(), outpoint.getIndex())).map(TransactionOutPoint::bitcoinSerialize) + .sorted(new Utils.LexicographicByteArrayComparator()) + .findFirst().orElseThrow(() -> new IllegalArgumentException("No inputs provided to calculate silent payments input hash")); + } + + public static ECKey getLabelledSpendKey(ECKey scanPrivateKey, ECKey spendPublicKey, int labelIndex) { + BigInteger labelTweak = new BigInteger(1, Utils.taggedHash(BIP_0352_LABEL_TAG, + Utils.concat(scanPrivateKey.getPrivKeyBytes(), ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(labelIndex).array()))); + return spendPublicKey.add(ECKey.fromPublicOnly(ECKey.publicPointFromPrivate(labelTweak).getEncoded(true)), true); + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index a804921..0dd67af 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -13,6 +13,8 @@ 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.silentpayments.SilentPayment; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentUtils; import java.nio.charset.StandardCharsets; import java.util.*; @@ -319,6 +321,11 @@ public class Wallet extends Persistable implements Comparable { return !isMasterWallet() && getKeystores().size() == 1 && getKeystores().get(0).getSource() == KeystoreSource.SW_PAYMENT_CODE; } + public boolean canSendSilentPayments() { + return getKeystores().size() == 1 && getKeystores().getFirst().hasPrivateKey() && policyType == PolicyType.SINGLE + && SilentPayment.VALID_INPUT_SCRIPT_TYPES.contains(scriptType); + } + public StandardAccount getStandardAccountType() { int accountIndex = getAccountIndex(); return Arrays.stream(StandardAccount.values()).filter(standardAccount -> standardAccount.getChildNumber().num() == accountIndex).findFirst().orElse(null); @@ -1063,6 +1070,12 @@ public class Wallet extends Persistable implements Comparable { txPayments.add(fakeMixPayment); } + List silentPayments = payments.stream().filter(payment -> payment instanceof SilentPayment) + .map(payment -> (SilentPayment)payment).collect(Collectors.toList()); + if(!silentPayments.isEmpty()) { + SilentPaymentUtils.updateSilentPayments(silentPayments, selectedUtxos); + } + //Add recipient outputs for(Payment payment : txPayments) { transaction.addOutput(payment.getAmount(), payment.getAddress()); diff --git a/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java index 6286382..eb1fe6d 100644 --- a/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java +++ b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentScanAddressTest.java @@ -37,6 +37,18 @@ public class SilentPaymentScanAddressTest { Assertions.assertEquals("tsp1qqgksl44sjwjkedsmrfmf2xqsnyt2njtjp5plk2kzjlnd9el2n76awqe5j974lvkf2utv7nrg0eaug55z86n6n3v4e9alnftdzgqk6pqmm5dphvxn", silentPaymentScanAddress.getAddress()); } + @Test + public void testLabels() { + ECKey scanPrivateKey = ECKey.fromPrivate(Utils.hexToBytes("0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c")); + ECKey spendPrivateKey = ECKey.fromPrivate(Utils.hexToBytes("9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3")); + + SilentPaymentScanAddress unlabelled = SilentPaymentScanAddress.from(scanPrivateKey, ECKey.fromPublicOnly(spendPrivateKey)); + Assertions.assertEquals("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", unlabelled.getAddress()); + Assertions.assertEquals("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq", unlabelled.getLabelledAddress(2).getAddress()); + Assertions.assertEquals("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n", unlabelled.getLabelledAddress(3).getAddress()); + Assertions.assertEquals("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5", unlabelled.getLabelledAddress(1001337).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 index 9bb3a46..d7d195b 100644 --- a/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtilsTest.java +++ b/src/test/java/com/sparrowwallet/drongo/silentpayments/SilentPaymentUtilsTest.java @@ -2,12 +2,17 @@ package com.sparrowwallet.drongo.silentpayments; import com.sparrowwallet.drongo.Utils; 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.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import java.util.HashMap; -import java.util.Map; +import java.util.*; public class SilentPaymentUtilsTest { @Test @@ -59,4 +64,437 @@ public class SilentPaymentUtilsTest { Assertions.assertFalse(SilentPaymentUtils.containsTaprootOutput(transaction)); } + + @Test + public void testSimpleSendTwoInputs() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSimpleSendTwoInputsReversed() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSimpleSendTwoInputsSameTransaction() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 3, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 7, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("79e71baa2ba3fc66396de3a04f168c7bf24d6870ec88ca877754790c1db357b6", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSimpleSendTwoInputsSameTransactionReversed() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 7, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 3, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("f4c2da807f89cb1501f1a77322a895acfb93c28e08ed2724d2beb8e44539ba38", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testOutpointOrderingIndex() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 1, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 256, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("a85ef8701394b517a4b35217c4bd37ac01ebeed4b008f8d0879f9e09ba95319c", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSingleRecipientSamePubKey() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("548ae55c8eec1e736e8d3e520f011f1f42a56d166116ad210b3937599f87f566", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSingleRecipientTaprootOnlyEvenY() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2TR); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("fc8716a97a48ba9a05a98ae47b5cd201a25a7fd5d8b73c203c5f7b6b6b3b6ad7")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("de88bea8e7ffc9ce1af30d1132f910323c505185aec8eae361670421e749a1fb", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSingleRecipientTaprootOnlyMixedY() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2TR); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("1d37787c2b7116ee983e9f9c13269df29091b391c04db94239e0d2bc2182c3bf")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("77cab7dd12b10259ee82c6ea4b509774e33e7078e7138f568092241bf26b99f1", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSingleRecipientTaprootEvenYAndNonTaproot() { + Wallet taprootWallet = new Wallet(); + taprootWallet.setPolicyType(PolicyType.SINGLE); + taprootWallet.setScriptType(ScriptType.P2TR); + Map taprootPrivateKeys = new LinkedHashMap<>(); + + Wallet segwitWallet = new Wallet(); + segwitWallet.setPolicyType(PolicyType.SINGLE); + segwitWallet.setScriptType(ScriptType.P2WPKH); + Map segwitPrivateKeys = new LinkedHashMap<>(); + + Map utxos = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(taprootWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + taprootPrivateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(segwitWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("8d4751f6e8a3586880fb66c19ae277969bd5aa06f61c4ee2f1e2486efdf666d3")); + utxos.put(ref1, walletNode1); + segwitPrivateKeys.put(walletNode1, privKey1); + + TestKeystore taprootKeystore = new TestKeystore(taprootPrivateKeys); + taprootWallet.getKeystores().add(taprootKeystore); + taprootWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, taprootWallet.getKeystores(), 1)); + + TestKeystore segwitKeystore = new TestKeystore(segwitPrivateKeys); + segwitWallet.getKeystores().add(segwitKeystore); + segwitWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, segwitWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("30523cca96b2a9ae3c98beb5e60f7d190ec5bc79b2d11a0b2d4d09a608c448f0", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testSingleRecipientTaprootOddYAndNonTaproot() { + Wallet taprootWallet = new Wallet(); + taprootWallet.setPolicyType(PolicyType.SINGLE); + taprootWallet.setScriptType(ScriptType.P2TR); + Map taprootPrivateKeys = new LinkedHashMap<>(); + + Wallet segwitWallet = new Wallet(); + segwitWallet.setPolicyType(PolicyType.SINGLE); + segwitWallet.setScriptType(ScriptType.P2WPKH); + Map segwitPrivateKeys = new LinkedHashMap<>(); + + Map utxos = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(taprootWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("1d37787c2b7116ee983e9f9c13269df29091b391c04db94239e0d2bc2182c3bf")); + utxos.put(ref0, walletNode0); + taprootPrivateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(segwitWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("8d4751f6e8a3586880fb66c19ae277969bd5aa06f61c4ee2f1e2486efdf666d3")); + utxos.put(ref1, walletNode1); + segwitPrivateKeys.put(walletNode1, privKey1); + + TestKeystore taprootKeystore = new TestKeystore(taprootPrivateKeys); + taprootWallet.getKeystores().add(taprootKeystore); + taprootWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, taprootWallet.getKeystores(), 1)); + + TestKeystore segwitKeystore = new TestKeystore(segwitPrivateKeys); + segwitWallet.getKeystores().add(segwitKeystore); + segwitWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, segwitWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress, "", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(1, silentPayments.size()); + Assertions.assertEquals("359358f59ee9e9eec3f00bdf4882570fd5c182e451aa2650b788544aff012a3a", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + } + + @Test + public void testMultipleOutputsSameRecipient() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress0 = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + SilentPaymentAddress silentPaymentAddress1 = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress0, "First", 0, false), new SilentPayment(silentPaymentAddress1, "Second", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(2, silentPayments.size()); + Assertions.assertEquals("First", silentPayments.getFirst().getLabel()); + Assertions.assertEquals("f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + Assertions.assertEquals("Second", silentPayments.getLast().getLabel()); + Assertions.assertEquals("e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca", Utils.bytesToHex(silentPayments.getLast().getAddress().getData())); + } + + @Test + public void testMultipleOutputsMultipleRecipients() { + Wallet sendWallet = new Wallet(); + sendWallet.setPolicyType(PolicyType.SINGLE); + sendWallet.setScriptType(ScriptType.P2WPKH); + Map utxos = new LinkedHashMap<>(); + Map privateKeys = new LinkedHashMap<>(); + + WalletNode walletNode0 = new WalletNode(sendWallet, "/0/0"); + BlockTransactionHashIndex ref0 = new BlockTransactionHashIndex(Sha256Hash.wrap("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"), 0, null, null, 0, 0); + ECKey privKey0 = ECKey.fromPrivate(Utils.hexToBytes("eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1")); + utxos.put(ref0, walletNode0); + privateKeys.put(walletNode0, privKey0); + + WalletNode walletNode1 = new WalletNode(sendWallet, "/0/1"); + BlockTransactionHashIndex ref1 = new BlockTransactionHashIndex(Sha256Hash.wrap("a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"), 0, null, null, 0, 0); + ECKey privKey1 = ECKey.fromPrivate(Utils.hexToBytes("0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a")); + utxos.put(ref1, walletNode1); + privateKeys.put(walletNode1, privKey1); + + TestKeystore sendKeystore = new TestKeystore(privateKeys); + sendWallet.getKeystores().add(sendKeystore); + sendWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, sendWallet.getKeystores(), 1)); + + SilentPaymentAddress silentPaymentAddress0 = SilentPaymentAddress.from("sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"); + SilentPaymentAddress silentPaymentAddress1 = SilentPaymentAddress.from("sp1qqgrz6j0lcqnc04vxccydl0kpsj4frfje0ktmgcl2t346hkw30226xqupawdf48k8882j0strrvcmgg2kdawz53a54dd376ngdhak364hzcmynqtn"); + SilentPaymentAddress silentPaymentAddress2 = SilentPaymentAddress.from("sp1qqgrz6j0lcqnc04vxccydl0kpsj4frfje0ktmgcl2t346hkw30226xqupawdf48k8882j0strrvcmgg2kdawz53a54dd376ngdhak364hzcmynqtn"); + List silentPayments = List.of(new SilentPayment(silentPaymentAddress0, "First", 0, false), new SilentPayment(silentPaymentAddress1, "Second", 0, false), new SilentPayment(silentPaymentAddress2, "Third", 0, false)); + + SilentPaymentUtils.updateSilentPayments(silentPayments, utxos); + Assertions.assertEquals(3, silentPayments.size()); + Assertions.assertEquals("First", silentPayments.getFirst().getLabel()); + Assertions.assertEquals("f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac", Utils.bytesToHex(silentPayments.getFirst().getAddress().getData())); + Assertions.assertEquals("Second", silentPayments.get(1).getLabel()); + Assertions.assertEquals("841792c33c9dc6193e76744134125d40add8f2f4a96475f28ba150be032d64e8", Utils.bytesToHex(silentPayments.get(1).getAddress().getData())); + Assertions.assertEquals("Third", silentPayments.getLast().getLabel()); + Assertions.assertEquals("2e847bb01d1b491da512ddd760b8509617ee38057003d6115d00ba562451323a", Utils.bytesToHex(silentPayments.getLast().getAddress().getData())); + } + + private static class TestKeystore extends Keystore { + private final Map privateKeys; + + private TestKeystore(Map privateKeys) { + this.privateKeys = privateKeys; + } + + @Override + public ECKey getKey(WalletNode walletNode) { + return privateKeys.get(walletNode); + } + + @Override + public boolean hasPrivateKey() { + return true; + } + } }