add initial silent payments library support

This commit is contained in:
Craig Raw 2025-08-19 15:22:00 +02:00
parent d30cc4432c
commit e12fdfa47c
9 changed files with 365 additions and 0 deletions

View file

@ -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)); 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<ChildNumber> 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() { public KeyDerivation copy() {
return new KeyDerivation(masterFingerprint, derivationPath); return new KeyDerivation(masterFingerprint, derivationPath);
} }

View file

@ -121,6 +121,11 @@ public class Bech32 {
/** Encode a Bech32 string. */ /** Encode a Bech32 string. */
public static String encode(String hrp, int version, final byte[] values) { public static String encode(String hrp, int version, final byte[] values) {
Encoding encoding = (version == 0 ? Encoding.BECH32 : Encoding.BECH32M); 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)); return encode(hrp, encoding, encode(version, values));
} }

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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<ScriptType> SCRIPT_TYPES = List.of(ScriptType.P2TR, ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH);
public static boolean isEligible(Transaction tx, Map<HashIndex, TransactionOutput> spentOutputs) {
if(!containsTaprootOutput(tx)) {
return false;
}
if(getInputPubKeys(tx, spentOutputs).isEmpty()) {
return false;
}
if(spendsInvalidSegwitOutput(tx, spentOutputs)) {
return false;
}
return true;
}
public static List<ECKey> getInputPubKeys(Transaction tx, Map<HashIndex, TransactionOutput> spentOutputs) {
List<ECKey> 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<HashIndex, TransactionOutput> spentOutputs) {
for(TransactionInput input : tx.getInputs()) {
HashIndex hashIndex = new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex());
Script scriptPubKey = spentOutputs.get(hashIndex).getScript();
List<ScriptChunk> 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<HashIndex, TransactionOutput> spentOutputs) {
if(tx.getOutputs().stream().noneMatch(output -> ScriptType.P2TR.isScriptType(output.getScript()))) {
return null;
}
if(spendsInvalidSegwitOutput(tx, spentOutputs)) {
return null;
}
List<ECKey> 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;
}
}

View file

@ -21,5 +21,6 @@ open module com.sparrowwallet.drongo {
exports com.sparrowwallet.drongo.bip47; exports com.sparrowwallet.drongo.bip47;
exports com.sparrowwallet.drongo.dns; exports com.sparrowwallet.drongo.dns;
exports com.sparrowwallet.drongo.wallet.slip39; exports com.sparrowwallet.drongo.wallet.slip39;
exports com.sparrowwallet.drongo.silentpayments;
exports org.bitcoin; exports org.bitcoin;
} }

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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<HashIndex, TransactionOutput> 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<HashIndex, TransactionOutput> 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));
}
}