diff --git a/build.gradle b/build.gradle index f68a019..60a7288 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,7 @@ dependencies { exclude group: 'org.hamcrest', module: 'hamcrest-core' } testImplementation group: 'org.hamcrest', name: 'hamcrest-core', version: '2.2' + testImplementation 'junit:junit:4.13.1' } processResources { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e583..943f0cb 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8049c68..f398c33 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb..65dcd68 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' @@ -143,12 +143,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac diff --git a/gradlew.bat b/gradlew.bat index 53a6b23..6689b85 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java b/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java index 37efd09..67b1d50 100644 --- a/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java +++ b/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java @@ -90,8 +90,8 @@ public class ExtendedKey { ChildNumber childNumber; List path; - if(depth == 0) { - //Poorly formatted extended key, add first child path element + if(depth == 0 && !header.isPrivateKey()) { + //Poorly formatted public extended key, add first child path element childNumber = new ChildNumber(0, false); } else if ((i & ChildNumber.HARDENED_BIT) != 0) { childNumber = new ChildNumber(i ^ ChildNumber.HARDENED_BIT, true); //already hardened diff --git a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java index c5d17d7..6698930 100644 --- a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java +++ b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java @@ -22,7 +22,7 @@ public class OutputDescriptor { private static final String INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "; private static final String CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; - private static final Pattern XPUB_PATTERN = Pattern.compile("(\\[[^\\]]+\\])?(.pub[^/\\,)]{100,112})(/[/\\d*'hH<>;]+)?"); + private static final Pattern XPUB_PATTERN = Pattern.compile("(\\[[^\\]]+\\])?(.(?:pub|prv)[^/\\,)]{100,112})(/[/\\d*'hH<>;]+)?"); private static final Pattern PUBKEY_PATTERN = Pattern.compile("(\\[[^\\]]+\\])?(0[23][0-9a-fA-F]{32})"); private static final Pattern MULTI_PATTERN = Pattern.compile("multi\\(([\\d+])"); private static final Pattern KEY_ORIGIN_PATTERN = Pattern.compile("\\[([A-Fa-f0-9]{8})([/\\d'hH]+)\\]"); @@ -379,6 +379,13 @@ public class OutputDescriptor { KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationPath); try { ExtendedKey extendedPublicKey = ExtendedKey.fromDescriptor(extPubKey); + if(extendedPublicKey.getKey().hasPrivKey()) { + List derivation = keyDerivation.getDerivation(); + int depth = derivation.size() == 0 ? scriptType.getDefaultDerivation().size() : derivation.size(); + DeterministicKey prvKey = extendedPublicKey.getKey(); + DeterministicKey pubKey = new DeterministicKey(prvKey.getPath(), prvKey.getChainCode(), prvKey.getPubKey(), depth, extendedPublicKey.getParentFingerprint()); + extendedPublicKey = new ExtendedKey(pubKey, pubKey.getParentFingerprint(), extendedPublicKey.getKeyChildNumber()); + } keyDerivationMap.put(extendedPublicKey, keyDerivation); keyChildDerivationMap.put(extendedPublicKey, childDerivationPath); } catch(ProtocolException e) { @@ -479,6 +486,10 @@ public class OutputDescriptor { } public String toString(boolean addKeyOrigin, boolean addChecksum) { + return toString(addKeyOrigin, true, addChecksum); + } + + public String toString(boolean addKeyOrigin, boolean addKey, boolean addChecksum) { StringBuilder builder = new StringBuilder(); builder.append(scriptType.getDescriptor()); @@ -487,14 +498,14 @@ public class OutputDescriptor { StringJoiner joiner = new StringJoiner(","); joiner.add(Integer.toString(multisigThreshold)); for(ExtendedKey pubKey : sortExtendedPubKeys(extendedPublicKeys.keySet())) { - String extKeyString = toString(pubKey, addKeyOrigin); + String extKeyString = toString(pubKey, addKeyOrigin, addKey); joiner.add(extKeyString); } builder.append(joiner.toString()); builder.append(ScriptType.MULTISIG.getCloseDescriptor()); } else { ExtendedKey extendedPublicKey = getSingletonExtendedPublicKey(); - builder.append(toString(extendedPublicKey, addKeyOrigin)); + builder.append(toString(extendedPublicKey, addKeyOrigin, addKey)); } builder.append(scriptType.getCloseDescriptor()); @@ -548,7 +559,7 @@ public class OutputDescriptor { } } - private String toString(ExtendedKey pubKey, boolean addKeyOrigin) { + private String toString(ExtendedKey pubKey, boolean addKeyOrigin, boolean addKey) { StringBuilder keyBuilder = new StringBuilder(); KeyDerivation keyDerivation = extendedPublicKeys.get(pubKey); if(keyDerivation != null && keyDerivation.getDerivationPath() != null && addKeyOrigin) { @@ -561,17 +572,19 @@ public class OutputDescriptor { keyBuilder.append("]"); } - if(pubKey != null) { - keyBuilder.append(pubKey.toString()); - } - - String childDerivation = mapChildrenDerivations.get(pubKey); - if(childDerivation != null) { - if(!childDerivation.startsWith("/")) { - keyBuilder.append("/"); + if(addKey) { + if(pubKey != null) { + keyBuilder.append(pubKey.toString()); } - keyBuilder.append(childDerivation); + String childDerivation = mapChildrenDerivations.get(pubKey); + if(childDerivation != null) { + if(!childDerivation.startsWith("/")) { + keyBuilder.append("/"); + } + + keyBuilder.append(childDerivation); + } } return keyBuilder.toString(); diff --git a/src/main/java/com/sparrowwallet/drongo/Utils.java b/src/main/java/com/sparrowwallet/drongo/Utils.java index 34f8d5b..554c279 100644 --- a/src/main/java/com/sparrowwallet/drongo/Utils.java +++ b/src/main/java/com/sparrowwallet/drongo/Utils.java @@ -13,6 +13,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.math.BigInteger; import java.nio.ByteBuffer; +import java.nio.charset.CharsetDecoder; import java.nio.charset.StandardCharsets; import java.util.*; @@ -22,6 +23,7 @@ public class Utils { public static final String HEX_REGEX = "^[0-9A-Fa-f]+$"; public static final String BASE64_REGEX = "^[0-9A-Za-z\\\\+=/]+$"; + public static final String NUMERIC_REGEX = "^-?\\d+(\\.\\d+)?$"; public static boolean isHex(String s) { return s.matches(HEX_REGEX); @@ -31,6 +33,20 @@ public class Utils { return s.matches(BASE64_REGEX); } + public static boolean isNumber(String s) { + return s.matches(NUMERIC_REGEX); + } + + public static boolean isUtf8(byte[] bytes) { + try { + CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder(); + decoder.decode(java.nio.ByteBuffer.wrap(bytes)); + return true; + } catch(Exception e) { + return false; + } + } + public static String bytesToHex(byte[] bytes) { char[] hexChars = new char[bytes.length * 2]; for ( int j = 0; j < bytes.length; j++ ) { @@ -128,6 +144,20 @@ public class Utils { return c; } + public static byte[] xor(byte[] a, byte[] b) { + if(a.length != b.length) { + throw new IllegalArgumentException("Invalid length for xor: " + a.length + " vs " + b.length); + } + + byte[] ret = new byte[a.length]; + + for(int i = 0; i < a.length; i++) { + ret[i] = (byte) ((int) b[i] ^ (int) a[i]); + } + + return ret; + } + /** Parse 4 bytes from the byte array (starting at the offset) as unsigned 32-bit integer in little endian format. */ public static long readUint32(byte[] bytes, int offset) { return (bytes[offset] & 0xffl) | diff --git a/src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java index fc9054d..3558787 100644 --- a/src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java @@ -71,7 +71,7 @@ public class PaymentAddress { private BigInteger addSecp256k1(BigInteger b1, BigInteger b2) { BigInteger ret = b1.add(b2); - if(ret.bitLength() > CURVE.getN().bitLength()) { + if(ret.compareTo(CURVE.getN()) > 0) { return ret.mod(CURVE.getN()); } @@ -83,7 +83,7 @@ public class PaymentAddress { } private boolean isSecp256k1(BigInteger b) { - return b.compareTo(BigInteger.ONE) > 0 && b.bitLength() <= CURVE.getN().bitLength(); + return (b.compareTo(BigInteger.ONE) > 0) && (b.compareTo(CURVE.getN()) < 0); } private BigInteger secretPoint() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException, NotSecp256k1Exception { diff --git a/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java index d997809..d730b71 100644 --- a/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java +++ b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java @@ -20,6 +20,8 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import static com.sparrowwallet.drongo.Utils.xor; + public class PaymentCode { private static final Logger log = LoggerFactory.getLogger(PaymentCode.class); @@ -313,21 +315,6 @@ public class PaymentCode { return HDKeyDerivation.createMasterPubKeyFromBytes(pubkey, chain); } - private static byte[] xor(byte[] a, byte[] b) { - if(a.length != b.length) { - log.error("Invalid length for xor: " + a.length + " vs " + b.length); - return null; - } - - byte[] ret = new byte[a.length]; - - for(int i = 0; i < a.length; i++) { - ret[i] = (byte) ((int) b[i] ^ (int) a[i]); - } - - return ret; - } - public boolean isValid() { try { byte[] pcodeBytes = Base58.decodeChecked(strPaymentCode); diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/Bip322.java b/src/main/java/com/sparrowwallet/drongo/crypto/Bip322.java new file mode 100644 index 0000000..700e158 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/Bip322.java @@ -0,0 +1,170 @@ +package com.sparrowwallet.drongo.crypto; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; +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.psbt.PSBTInputSigner; +import com.sparrowwallet.drongo.psbt.PSBTSignatureException; + +import java.nio.charset.StandardCharsets; +import java.security.SignatureException; +import java.util.*; + +import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR; + +public class Bip322 { + public static String signMessageBip322(ScriptType scriptType, String message, ECKey privKey) { + checkScriptType(scriptType); + + ECKey pubKey = ECKey.fromPublicOnly(privKey); + Address address = scriptType.getAddress(pubKey); + + Transaction toSpend = getBip322ToSpend(address, message); + Transaction toSign = getBip322ToSign(toSpend); + + TransactionOutput utxoOutput = toSpend.getOutputs().get(0); + + PSBT psbt = new PSBT(toSign); + PSBTInput psbtInput = psbt.getPsbtInputs().get(0); + psbtInput.setWitnessUtxo(utxoOutput); + psbtInput.setSigHash(SigHash.ALL); + psbtInput.sign(scriptType.getOutputKey(privKey)); + + TransactionSignature signature = psbtInput.isTaproot() ? psbtInput.getTapKeyPathSignature() : psbtInput.getPartialSignature(pubKey); + + Transaction finalizeTransaction = new Transaction(); + TransactionInput finalizedTxInput = scriptType.addSpendingInput(finalizeTransaction, utxoOutput, pubKey, signature); + + return 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()) { + throw new SignatureException("Provided signature is empty."); + } + + byte[] signatureEncoded; + try { + signatureEncoded = Base64.getDecoder().decode(signatureBase64); + } catch(IllegalArgumentException e) { + throw new SignatureException("Could not decode base64 signature", e); + } + + TransactionWitness witness; + try { + witness = new TransactionWitness(null, signatureEncoded, 0); + } catch(Exception e) { + throw new SignatureException("Provided signature is not a BIP322 simple signature.", e); + } + + TransactionSignature signature; + ECKey pubKey; + + if(witness.getWitnessScript() != null) { + throw new IllegalArgumentException("Multisig signatures are not supported."); + } + + if(witness.getSignatures().isEmpty()) { + throw new SignatureException("BIP322 simple signature contains no transaction signatures."); + } + + if(scriptType == ScriptType.P2WPKH) { + signature = witness.getSignatures().get(0); + if(witness.getPushes().size() <= 1) { + throw new SignatureException("BIP322 simple signature for P2WPKH script type does not contain a pubkey."); + } + pubKey = ECKey.fromPublicOnly(witness.getPushes().get(1)); + + if(!address.equals(scriptType.getAddress(pubKey))) { + throw new SignatureException("Provided address does not match pubkey in signature"); + } + } else if(scriptType == ScriptType.P2TR) { + signature = witness.getSignatures().get(0); + pubKey = P2TR.getPublicKeyFromScript(address.getOutputScript()); + } else { + throw new SignatureException(scriptType + " addresses are not supported"); + } + + Transaction toSpend = getBip322ToSpend(address, message); + Transaction toSign = getBip322ToSign(toSpend); + + PSBT psbt = new PSBT(toSign); + PSBTInput psbtInput = psbt.getPsbtInputs().get(0); + psbtInput.setWitnessUtxo(toSpend.getOutputs().get(0)); + psbtInput.setSigHash(SigHash.ALL); + + if(scriptType == ScriptType.P2TR) { + psbtInput.setTapKeyPathSignature(signature); + } else { + psbtInput.getPartialSignatures().put(pubKey, signature); + } + + try { + psbt.verifySignatures(); + } catch(PSBTSignatureException e) { + return false; + } + + return true; + } + + private static void checkScriptType(ScriptType scriptType) { + if(!scriptType.isAllowed(PolicyType.SINGLE)) { + throw new UnsupportedOperationException("Only singlesig addresses are currently supported"); + } + + if(!Arrays.asList(ScriptType.WITNESS_TYPES).contains(scriptType)) { + throw new UnsupportedOperationException("Legacy addresses are not supported for BIP322 simple signatures"); + } + + if(scriptType == ScriptType.P2SH_P2WPKH) { + throw new UnsupportedOperationException("The P2SH-P2WPKH script type is not currently supported"); + } + } + + public static boolean isSupported(ScriptType scriptType) { + return scriptType == ScriptType.P2WPKH || scriptType == P2TR; + } + + public static Transaction getBip322ToSpend(Address address, String message) { + Transaction toSpend = new Transaction(); + toSpend.setVersion(0); + toSpend.setLocktime(0); + + List scriptSigChunks = new ArrayList<>(); + scriptSigChunks.add(ScriptChunk.fromOpcode(ScriptOpCodes.OP_0)); + scriptSigChunks.add(ScriptChunk.fromData(getBip322MessageHash(message))); + Script scriptSig = new Script(scriptSigChunks); + toSpend.addInput(Sha256Hash.ZERO_HASH, 0xFFFFFFFFL, scriptSig, new TransactionWitness(toSpend, Collections.emptyList())); + toSpend.getInputs().get(0).setSequenceNumber(0L); + toSpend.addOutput(0L, address.getOutputScript()); + + return toSpend; + } + + public static Transaction getBip322ToSign(Transaction toSpend) { + Transaction toSign = new Transaction(); + toSign.setVersion(0); + toSign.setLocktime(0); + + TransactionWitness witness = new TransactionWitness(toSign); + toSign.addInput(toSpend.getTxId(), 0L, new Script(new byte[0]), witness); + toSign.getInputs().get(0).setSequenceNumber(0L); + toSign.addOutput(0, new Script(List.of(ScriptChunk.fromOpcode(ScriptOpCodes.OP_RETURN)))); + + return toSign; + } + + public static byte[] getBip322MessageHash(String message) { + if(message == null) { + throw new IllegalArgumentException("Message cannot be null"); + } + + return Utils.taggedHash("BIP0322-signed-message", message.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java index 7954be3..fbd1205 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java @@ -576,9 +576,13 @@ public class ECKey { * @throws IllegalStateException if this ECKey does not have the private part. */ public String signMessage(String message, ScriptType scriptType) { + return signMessage(message, scriptType, this::signEcdsa); + } + + public String signMessage(String message, ScriptType scriptType, ECDSAHashSigner ecdsaHashSigner) { byte[] data = formatMessageForSigning(message); Sha256Hash hash = Sha256Hash.of(data); - ECDSASignature sig = signEcdsa(hash); + ECDSASignature sig = ecdsaHashSigner.sign(hash); byte recId = findRecoveryId(hash, sig); int headerByte = recId + getSigningTypeConstant(scriptType); byte[] sigData = new byte[65]; // 1 header + 32 bytes for R + 32 bytes for S @@ -627,6 +631,14 @@ public class ECKey { // This is what you get back from Bouncy Castle if base64 doesn't decode :( throw new SignatureException("Could not decode base64", e); } + byte[] messageBytes = formatMessageForSigning(message); + // Note that the C++ code doesn't actually seem to specify any character encoding. Presumably it's whatever + // JSON-SPIRIT hands back. Assume UTF-8 for now. + Sha256Hash messageHash = Sha256Hash.twiceOf(messageBytes); + return signedHashToKey(messageHash, signatureEncoded, electrumFormat); + } + + public static ECKey signedHashToKey(Sha256Hash messageHash, byte[] signatureEncoded, boolean electrumFormat) throws SignatureException { // Parse the signature bytes into r/s and the selector value. if(signatureEncoded.length < 65) { throw new SignatureException("Signature truncated, expected 65 bytes and got " + signatureEncoded.length); @@ -640,10 +652,7 @@ public class ECKey { BigInteger r = new BigInteger(1, Arrays.copyOfRange(signatureEncoded, 1, 33)); BigInteger s = new BigInteger(1, Arrays.copyOfRange(signatureEncoded, 33, 65)); ECDSASignature sig = new ECDSASignature(r, s); - byte[] messageBytes = formatMessageForSigning(message); - // Note that the C++ code doesn't actually seem to specify any character encoding. Presumably it's whatever - // JSON-SPIRIT hands back. Assume UTF-8 for now. - Sha256Hash messageHash = Sha256Hash.of(messageBytes); + boolean compressed = false; if(header >= 39) { // this is a bech32 signature header -= 12; @@ -863,4 +872,8 @@ public class ECKey { throw new RuntimeException(e); // Cannot happen. } } + + public interface ECDSAHashSigner { + ECDSASignature sign(Sha256Hash hash); + } } diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/Pbkdf2KeyDeriver.java b/src/main/java/com/sparrowwallet/drongo/crypto/Pbkdf2KeyDeriver.java index 578b27b..d3e57dc 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/Pbkdf2KeyDeriver.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/Pbkdf2KeyDeriver.java @@ -7,25 +7,36 @@ import org.bouncycastle.crypto.params.KeyParameter; public class Pbkdf2KeyDeriver implements KeyDeriver, AsymmetricKeyDeriver { public static final int DEFAULT_ITERATION_COUNT = 1024; + public static final int DEFAULT_KEY_SIZE = 512; private final byte[] salt; private final int iterationCount; + private final int keySize; public static final Pbkdf2KeyDeriver DEFAULT_INSTANCE = new Pbkdf2KeyDeriver(); public Pbkdf2KeyDeriver() { this.salt = new byte[0]; this.iterationCount = DEFAULT_ITERATION_COUNT; + this.keySize = DEFAULT_KEY_SIZE; } public Pbkdf2KeyDeriver(byte[] salt) { this.salt = salt; this.iterationCount = DEFAULT_ITERATION_COUNT; + this.keySize = DEFAULT_KEY_SIZE; } public Pbkdf2KeyDeriver(byte[] salt, int iterationCount) { this.salt = salt; this.iterationCount = iterationCount; + this.keySize = DEFAULT_KEY_SIZE; + } + + public Pbkdf2KeyDeriver(byte[] salt, int iterationCount, int keySize) { + this.salt = salt; + this.iterationCount = iterationCount; + this.keySize = keySize; } @Override @@ -37,7 +48,7 @@ public class Pbkdf2KeyDeriver implements KeyDeriver, AsymmetricKeyDeriver { public Key deriveKey(CharSequence password) throws KeyCrypterException { PKCS5S2ParametersGenerator gen = new PKCS5S2ParametersGenerator(new SHA512Digest()); gen.init(SecureString.toBytesUTF8(password), salt, iterationCount); - byte[] keyBytes = ((KeyParameter)gen.generateDerivedParameters(512)).getKey(); + byte[] keyBytes = ((KeyParameter)gen.generateDerivedParameters(keySize)).getKey(); return new Key(keyBytes, salt, getDeriverType()); } diff --git a/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java b/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java index 98294b5..d92883c 100644 --- a/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java +++ b/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java @@ -6,7 +6,7 @@ import java.util.regex.Pattern; public class Miniscript { private static final Pattern SINGLE_PATTERN = Pattern.compile("pkh?\\("); private static final Pattern TAPROOT_PATTERN = Pattern.compile("tr\\("); - private static final Pattern MULTI_PATTERN = Pattern.compile("multi\\(([\\d+])"); + private static final Pattern MULTI_PATTERN = Pattern.compile("multi\\((\\d+)"); private String script; diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Script.java b/src/main/java/com/sparrowwallet/drongo/protocol/Script.java index b79025e..b821092 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Script.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Script.java @@ -4,6 +4,8 @@ import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.address.*; import com.sparrowwallet.drongo.crypto.ECKey; import org.bouncycastle.util.encoders.Hex; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -18,6 +20,8 @@ import static com.sparrowwallet.drongo.protocol.ScriptType.*; import static com.sparrowwallet.drongo.protocol.ScriptOpCodes.*; public class Script { + private static final Logger log = LoggerFactory.getLogger(Script.class); + public static final long MAX_SCRIPT_ELEMENT_SIZE = 520; // The program is a set of chunks where each element is either [opcode] or [data, data, data ...] @@ -32,7 +36,11 @@ public class Script { Script(byte[] programBytes, boolean parse) { program = programBytes; if(parse) { - parse(); + try { + parse(); + } catch(ProtocolException e) { + log.warn("Invalid script, continuing with already parsed chunks", e); + } } } @@ -59,16 +67,16 @@ public class Script { // Read some bytes of data, where how many is the opcode value itself. dataToRead = opcode; } else if (opcode == OP_PUSHDATA1) { - if (bis.available() < 1) throw new ProtocolException("Unexpected end of script"); + if (bis.available() < 1) throw new ProtocolException("Unexpected end of script - OP_PUSHDATA1 was followed by " + bis.available() + " bytes"); dataToRead = bis.read(); } else if (opcode == OP_PUSHDATA2) { // Read a short, then read that many bytes of data. - if (bis.available() < 2) throw new ProtocolException("Unexpected end of script"); + if (bis.available() < 2) throw new ProtocolException("Unexpected end of script - OP_PUSHDATA2 was followed by only " + bis.available() + " bytes"); dataToRead = Utils.readUint16FromStream(bis); } else if (opcode == OP_PUSHDATA4) { // Read a uint32, then read that many bytes of data. // Though this is allowed, because its value cannot be > 520, it should never actually be used - if (bis.available() < 4) throw new ProtocolException("Unexpected end of script"); + if (bis.available() < 4) throw new ProtocolException("Unexpected end of script - OP_PUSHDATA4 was followed by only " + bis.available() + " bytes"); dataToRead = Utils.readUint32FromStream(bis); } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java index f74b4d4..ce559a1 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java @@ -7,6 +7,8 @@ import org.bouncycastle.util.encoders.Hex; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; @@ -28,7 +30,11 @@ public class ScriptChunk { } public static ScriptChunk fromOpcode(int opcode) { - return new ScriptChunk(opcode, null); + return new ScriptChunk(opcode, opcode == ScriptOpCodes.OP_0 ? new byte[0] : null); + } + + public static ScriptChunk fromString(String strData, Charset charset) { + return fromData(strData.getBytes(charset)); } public static ScriptChunk fromData(byte[] data) { @@ -68,7 +74,7 @@ public class ScriptChunk { } public void write(OutputStream stream) throws IOException { - if (isOpCode()) { + if (isOpCode() && opcode != ScriptOpCodes.OP_0) { if(data != null) throw new IllegalStateException("Data must be null for opcode chunk"); stream.write(opcode); } else if (data != null) { @@ -126,6 +132,18 @@ public class ScriptChunk { } } + public boolean isString() { + if(data == null || data.length == 0) { + return false; + } + + if(isSignature() || isPubKey()) { + return false; + } + + return Utils.isUtf8(data); + } + public boolean isScript() { if(data == null || data.length == 0) { return false; @@ -213,6 +231,9 @@ public class ScriptChunk { if (data.length == 0) { return "OP_0"; } + if(Utils.isUtf8(data)) { + return new String(data, StandardCharsets.UTF_8); + } return Hex.toHexString(data); } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java index 959175a..a371546 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java @@ -1320,7 +1320,7 @@ public enum ScriptType { //Start with length of output int outputVbytes = output.getLength(); //Add length of spending input (with or without discount depending on script type) - int inputVbytes = getInputVbytes(); + double inputVbytes = getInputVbytes(); //Return fee rate in sats/vByte multiplied by the calculated output and input vByte lengths return (long)(feeRate * outputVbytes + longTermFeeRate * inputVbytes); @@ -1333,17 +1333,17 @@ public enum ScriptType { * * @return The number of vBytes required for an input of this script type */ - public int getInputVbytes() { + public double getInputVbytes() { if(P2SH_P2WPKH.equals(this)) { - return (32 + 4 + 1 + 13 + (107 / WITNESS_SCALE_FACTOR) + 4); + return (32 + 4 + 1 + 13 + ((double)107 / WITNESS_SCALE_FACTOR) + 4); } else if(P2SH_P2WSH.equals(this)) { - return (32 + 4 + 1 + 35 + (107 / WITNESS_SCALE_FACTOR) + 4); + return (32 + 4 + 1 + 35 + ((double)107 / WITNESS_SCALE_FACTOR) + 4); } else if(P2TR.equals(this)) { //Assume a default keypath spend - return (32 + 4 + 1 + (66 / WITNESS_SCALE_FACTOR) + 4); + return (32 + 4 + 1 + ((double)66 / WITNESS_SCALE_FACTOR) + 4); } else if(Arrays.asList(WITNESS_TYPES).contains(this)) { //Return length of spending input with 75% discount to script size - return (32 + 4 + 1 + (107 / WITNESS_SCALE_FACTOR) + 4); + return (32 + 4 + 1 + ((double)107 / WITNESS_SCALE_FACTOR) + 4); } else if(Arrays.asList(NON_WITNESS_TYPES).contains(this)) { //Return length of spending input with no discount return (32 + 4 + 1 + 107 + 4); diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Sha256Hash.java b/src/main/java/com/sparrowwallet/drongo/protocol/Sha256Hash.java index 11c0269..ce3650d 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Sha256Hash.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Sha256Hash.java @@ -61,6 +61,18 @@ public class Sha256Hash implements Comparable { return wrap(Utils.hexToBytes(hexString)); } + /** + * Duplicate method with default name for deserialization mappers. + * + * @param hexString a hash value represented as a hex string + * @return a new instance + * @throws IllegalArgumentException if the given string is not a valid + * hex string, or if it does not represent exactly 32 bytes + */ + public static Sha256Hash fromString(String hexString) { + return wrap(hexString); + } + /** * Creates a new instance that wraps the given hash value, but with byte order reversed. * diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java b/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java index 33d7697..d94cfe9 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java @@ -8,10 +8,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; +import java.util.*; import static com.sparrowwallet.drongo.Utils.uint32ToByteStreamLE; import static com.sparrowwallet.drongo.Utils.uint64ToByteStreamLE; @@ -634,6 +631,9 @@ public class Transaction extends ChildMessage { if(spentUtxos.size() != getInputs().size()) { throw new IllegalArgumentException("Provided spent UTXOs length does not equal the number of transaction inputs"); } + if(spentUtxos.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("Not all spent UTXOs are provided"); + } if(inputIndex >= getInputs().size()) { throw new IllegalArgumentException("Input index is greater than the number of transaction inputs"); } @@ -685,7 +685,7 @@ public class Transaction extends ChildMessage { if(anyoneCanPay) { getInputs().get(inputIndex).getOutpoint().bitcoinSerializeToStream(bos); - Utils.uint32ToByteStreamLE(spentUtxos.get(inputIndex).getValue(), bos); + Utils.int64ToByteStreamLE(spentUtxos.get(inputIndex).getValue(), bos); byteArraySerialize(spentUtxos.get(inputIndex).getScriptBytes(), bos); Utils.uint32ToByteStreamLE(getInputs().get(inputIndex).getSequenceNumber(), bos); } else { diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/TransactionSignature.java b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionSignature.java index e9b173e..15e3299 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/TransactionSignature.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionSignature.java @@ -103,7 +103,7 @@ public class TransactionSignature { } public static TransactionSignature decodeFromBitcoin(byte[] bytes, boolean requireCanonicalEncoding) throws SignatureDecodeException { - if(bytes.length == 64) { + if(bytes.length == 64 || bytes.length == 65) { return decodeFromBitcoin(Type.SCHNORR, bytes, requireCanonicalEncoding); } diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java index cf79731..3c86bed 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java @@ -461,10 +461,10 @@ public class PSBT { } public byte[] serialize() { - return serialize(true); + return serialize(true, true); } - public byte[] serialize(boolean includeXpubs) { + public byte[] serialize(boolean includeXpubs, boolean includeNonWitnessUtxos) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); baos.writeBytes(Utils.hexToBytes(PSBT_MAGIC_HEX)); @@ -481,8 +481,9 @@ public class PSBT { for(PSBTInput psbtInput : getPsbtInputs()) { List inputEntries = psbtInput.getInputEntries(); for(PSBTEntry entry : inputEntries) { - if(includeXpubs || (entry.getKeyType() != PSBT_IN_BIP32_DERIVATION && entry.getKeyType() != PSBT_IN_PROPRIETARY - && entry.getKeyType() != PSBT_IN_TAP_INTERNAL_KEY && entry.getKeyType() != PSBT_IN_TAP_BIP32_DERIVATION)) { + if((includeXpubs || (entry.getKeyType() != PSBT_IN_BIP32_DERIVATION && entry.getKeyType() != PSBT_IN_PROPRIETARY + && entry.getKeyType() != PSBT_IN_TAP_INTERNAL_KEY && entry.getKeyType() != PSBT_IN_TAP_BIP32_DERIVATION)) + && (includeNonWitnessUtxos || entry.getKeyType() != PSBT_IN_NON_WITNESS_UTXO)) { entry.serializeToStream(baos); } } @@ -630,7 +631,7 @@ public class PSBT { } public String toBase64String(boolean includeXpubs) { - return Base64.toBase64String(serialize(includeXpubs)); + return Base64.toBase64String(serialize(includeXpubs, true)); } public static boolean isPSBT(byte[] b) { diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java index fa4326d..54be38f 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java @@ -188,7 +188,7 @@ public class PSBTEntry { psbtByteBuffer.get(buf); ByteBuffer byteBuffer = ByteBuffer.wrap(buf); byteBuffer.order(ByteOrder.LITTLE_ENDIAN); - return byteBuffer.getShort(); + return Short.toUnsignedInt(byteBuffer.getShort()); } case (byte) 0xfe: { byte[] buf = new byte[4]; diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java index 8bbae7e..eade24e 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java @@ -519,6 +519,20 @@ public class PSBTInput { } public boolean sign(ECKey privKey) { + return sign(new PSBTInputSigner() { + @Override + public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) { + return privKey.sign(hash, sigHash, signatureType); + } + + @Override + public ECKey getPubKey() { + return ECKey.fromPublicOnly(privKey); + } + }); + } + + public boolean sign(PSBTInputSigner psbtInputSigner) { SigHash localSigHash = getSigHash(); if(localSigHash == null) { localSigHash = getDefaultSigHash(); @@ -529,12 +543,12 @@ public class PSBTInput { if(signingScript != null) { Sha256Hash hash = getHashForSignature(signingScript, localSigHash); TransactionSignature.Type type = isTaproot() ? SCHNORR : ECDSA; - TransactionSignature transactionSignature = privKey.sign(hash, localSigHash, type); + TransactionSignature transactionSignature = psbtInputSigner.sign(hash, localSigHash, type); if(type == SCHNORR) { tapKeyPathSignature = transactionSignature; } else { - ECKey pubKey = ECKey.fromPublicOnly(privKey); + ECKey pubKey = psbtInputSigner.getPubKey(); getPartialSignatures().put(pubKey, transactionSignature); } diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInputSigner.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInputSigner.java new file mode 100644 index 0000000..6d73fb4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInputSigner.java @@ -0,0 +1,11 @@ +package com.sparrowwallet.drongo.psbt; + +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.SigHash; +import com.sparrowwallet.drongo.protocol.TransactionSignature; + +public interface PSBTInputSigner { + TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType); + ECKey getPubKey(); +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java b/src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java index 3dc0d21..a32b291 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java @@ -229,4 +229,85 @@ public class Bip39MnemonicCode { bits[(i * 8) + j] = (data[i] & (1 << (7 - j))) != 0; return bits; } + + public List getPossibleLastWords(List previousWords) throws MnemonicException.MnemonicLengthException, MnemonicException.MnemonicWordException { + if((previousWords.size() + 1) % 3 > 0) { + throw new MnemonicException.MnemonicLengthException("Previous word list size must be multiple of three words, less one."); + } + + // Look up all the words in the list and construct the + // concatenation of the original entropy and the checksum. + // + int concatLenBits = previousWords.size() * 11; + boolean[] concatBits = new boolean[concatLenBits]; + int wordindex = 0; + for (String word : previousWords) { + // Find the words index in the wordlist. + int ndx = Collections.binarySearch(this.wordList, word); + if (ndx < 0) { + throw new MnemonicException.MnemonicWordException(word); + } + // Set the next 11 bits to the value of the index. + for (int ii = 0; ii < 11; ++ii) { + concatBits[(wordindex * 11) + ii] = (ndx & (1 << (10 - ii))) != 0; + } + ++wordindex; + } + + int checksumLengthBits = (concatLenBits + 11) / 33; + int entropyLengthBits = (concatLenBits + 11) - checksumLengthBits; + int varyingLengthBits = entropyLengthBits - concatLenBits; + + boolean[][] bitPermutations = getBitPermutations(varyingLengthBits); + + ArrayList possibleWords = new ArrayList<>(); + for(boolean[] bitPermutation : bitPermutations) { + boolean[] entropyBits = new boolean[concatLenBits + varyingLengthBits]; + System.arraycopy(concatBits, 0, entropyBits, 0, concatBits.length); + System.arraycopy(bitPermutation, 0, entropyBits, concatBits.length, varyingLengthBits); + + byte[] entropy = new byte[entropyLengthBits / 8]; + for(int ii = 0; ii < entropy.length; ++ii) { + for(int jj = 0; jj < 8; ++jj) { + if(entropyBits[(ii * 8) + jj]) { + entropy[ii] |= 1 << (7 - jj); + } + } + } + + byte[] hash = Sha256Hash.hash(entropy); + boolean[] hashBits = bytesToBits(hash); + + boolean[] wordBits = new boolean[11]; + System.arraycopy(bitPermutation, 0, wordBits, 0, varyingLengthBits); + System.arraycopy(hashBits, 0, wordBits, varyingLengthBits, checksumLengthBits); + + int index = 0; + for(int j = 0; j < 11; ++j) { + index <<= 1; + if(wordBits[j]) { + index |= 0x1; + } + } + + possibleWords.add(this.wordList.get(index)); + } + + Collections.sort(possibleWords); + + return possibleWords; + } + + public static boolean[][] getBitPermutations(int length) { + int numPermutations = (int) Math.pow(2, length); + boolean[][] permutations = new boolean[numPermutations][length]; + + for (int i = 0; i < numPermutations; i++) { + for (int j = 0; j < length; j++) { + permutations[i][j] = ((i >> j) & 1) == 1; + } + } + + return permutations; + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseUtxoFilter.java b/src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseTxoFilter.java similarity index 87% rename from src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseUtxoFilter.java rename to src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseTxoFilter.java index 8f079f6..6c0e03c 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseUtxoFilter.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseTxoFilter.java @@ -2,10 +2,10 @@ package com.sparrowwallet.drongo.wallet; import com.sparrowwallet.drongo.protocol.Transaction; -public class CoinbaseUtxoFilter implements UtxoFilter { +public class CoinbaseTxoFilter implements TxoFilter { private final Wallet wallet; - public CoinbaseUtxoFilter(Wallet wallet) { + public CoinbaseTxoFilter(Wallet wallet) { this.wallet = wallet; } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/ExcludeTxoFilter.java b/src/main/java/com/sparrowwallet/drongo/wallet/ExcludeTxoFilter.java new file mode 100644 index 0000000..e92837c --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/ExcludeTxoFilter.java @@ -0,0 +1,31 @@ +package com.sparrowwallet.drongo.wallet; + +import java.util.ArrayList; +import java.util.Collection; + +public class ExcludeTxoFilter implements TxoFilter { + private final Collection excludedTxos; + + public ExcludeTxoFilter() { + this.excludedTxos = new ArrayList<>(); + } + + public ExcludeTxoFilter(Collection excludedTxos) { + this.excludedTxos = new ArrayList<>(excludedTxos); + } + + @Override + public boolean isEligible(BlockTransactionHashIndex candidate) { + for(BlockTransactionHashIndex excludedTxo : excludedTxos) { + if(candidate.getHash().equals(excludedTxo.getHash()) && candidate.getIndex() == excludedTxo.getIndex()) { + return false; + } + } + + return true; + } + + public Collection getExcludedTxos() { + return excludedTxos; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/ExcludeUtxoFilter.java b/src/main/java/com/sparrowwallet/drongo/wallet/ExcludeUtxoFilter.java deleted file mode 100644 index f87ae7d..0000000 --- a/src/main/java/com/sparrowwallet/drongo/wallet/ExcludeUtxoFilter.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.sparrowwallet.drongo.wallet; - -import java.util.ArrayList; -import java.util.Collection; - -public class ExcludeUtxoFilter implements UtxoFilter { - private final Collection excludedUtxos; - - public ExcludeUtxoFilter() { - this.excludedUtxos = new ArrayList<>(); - } - - public ExcludeUtxoFilter(Collection excludedUtxos) { - this.excludedUtxos = new ArrayList<>(excludedUtxos); - } - - @Override - public boolean isEligible(BlockTransactionHashIndex candidate) { - for(BlockTransactionHashIndex excludedUtxo : excludedUtxos) { - if(candidate.getHash().equals(excludedUtxo.getHash()) && candidate.getIndex() == excludedUtxo.getIndex()) { - return false; - } - } - - return true; - } - - public Collection getExcludedUtxos() { - return excludedUtxos; - } -} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/FrozenUtxoFilter.java b/src/main/java/com/sparrowwallet/drongo/wallet/FrozenTxoFilter.java similarity index 80% rename from src/main/java/com/sparrowwallet/drongo/wallet/FrozenUtxoFilter.java rename to src/main/java/com/sparrowwallet/drongo/wallet/FrozenTxoFilter.java index 6b61dbc..3b50b74 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/FrozenUtxoFilter.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/FrozenTxoFilter.java @@ -1,6 +1,6 @@ package com.sparrowwallet.drongo.wallet; -public class FrozenUtxoFilter implements UtxoFilter { +public class FrozenTxoFilter implements TxoFilter { @Override public boolean isEligible(BlockTransactionHashIndex candidate) { return candidate.getStatus() == null || candidate.getStatus() != Status.FROZEN; diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java b/src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java index dca047e..3229edf 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java @@ -1,6 +1,8 @@ package com.sparrowwallet.drongo.wallet; public class InsufficientFundsException extends Exception { + private Long targetValue; + public InsufficientFundsException() { super(); } @@ -8,4 +10,13 @@ public class InsufficientFundsException extends Exception { public InsufficientFundsException(String msg) { super(msg); } + + public InsufficientFundsException(String message, Long targetValue) { + super(message); + this.targetValue = targetValue; + } + + public Long getTargetValue() { + return targetValue; + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java index 090014c..64728b4 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java @@ -1,22 +1,30 @@ package com.sparrowwallet.drongo.wallet; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; public class PresetUtxoSelector extends SingleSetUtxoSelector { private final Collection presetUtxos; + private final Collection excludedUtxos; private final boolean maintainOrder; + private final boolean requireAll; public PresetUtxoSelector(Collection presetUtxos) { - this.presetUtxos = presetUtxos; - this.maintainOrder = false; + this(presetUtxos, new ArrayList<>()); } - public PresetUtxoSelector(Collection presetUtxos, boolean maintainOrder) { + public PresetUtxoSelector(Collection presetUtxos, Collection excludedUtxos) { this.presetUtxos = presetUtxos; + this.excludedUtxos = excludedUtxos; + this.maintainOrder = false; + this.requireAll = false; + } + + public PresetUtxoSelector(Collection presetUtxos, boolean maintainOrder, boolean requireAll) { + this.presetUtxos = presetUtxos; + this.excludedUtxos = new ArrayList<>(); this.maintainOrder = maintainOrder; + this.requireAll = requireAll; } @Override @@ -33,8 +41,11 @@ public class PresetUtxoSelector extends SingleSetUtxoSelector { } } - if(maintainOrder && utxos.containsAll(presetUtxos)) { + Set utxosSet = new HashSet<>(utxos); + if(maintainOrder && utxosSet.containsAll(presetUtxos)) { return presetUtxos; + } else if(requireAll && !utxosSet.containsAll(presetUtxos)) { + return Collections.emptyList(); } return utxos; @@ -44,6 +55,17 @@ public class PresetUtxoSelector extends SingleSetUtxoSelector { return presetUtxos; } + public Collection getExcludedUtxos() { + return excludedUtxos; + } + + public TxoFilter asExcludeTxoFilter() { + List utxos = new ArrayList<>(); + utxos.addAll(presetUtxos); + utxos.addAll(excludedUtxos); + return new ExcludeTxoFilter(utxos); + } + @Override public boolean shuffleInputs() { return !maintainOrder; diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/SeedQR.java b/src/main/java/com/sparrowwallet/drongo/wallet/SeedQR.java index 30231cd..e26c06e 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/SeedQR.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/SeedQR.java @@ -31,23 +31,33 @@ public class SeedQR { } public static DeterministicSeed getSeed(byte[] compactSeedQr) { - if(compactSeedQr[0] != 0x41 && compactSeedQr[0] != 0x42) { - throw new IllegalArgumentException("Invalid CompactSeedQR header"); - } + byte[] seed; - if(compactSeedQr.length < 19) { - throw new IllegalArgumentException("Invalid CompactSeedQR length"); - } - - String qrHex = Utils.bytesToHex(compactSeedQr); - String seedHex; - if(qrHex.endsWith("0ec")) { - seedHex = qrHex.substring(3, qrHex.length() - 3); + if(compactSeedQr.length == 16 || compactSeedQr.length == 32) { + //Assume scan contains seed only + seed = compactSeedQr; } else { - seedHex = qrHex.substring(3, qrHex.length() - 1); - } + //Assume scan contains header, seed and EC bytes + if(compactSeedQr[0] != 0x41 && compactSeedQr[0] != 0x42) { + throw new IllegalArgumentException("Invalid CompactSeedQR header"); + } - byte[] seed = Utils.hexToBytes(seedHex); + if(compactSeedQr.length < 19) { + throw new IllegalArgumentException("Invalid CompactSeedQR length"); + } + + String qrHex = Utils.bytesToHex(compactSeedQr); + String seedHex; + if(qrHex.endsWith("0ec11ec11")) { + seedHex = qrHex.substring(3, qrHex.length() - 9); //12 word, high EC + } else if(qrHex.endsWith("0ec")) { + seedHex = qrHex.substring(3, qrHex.length() - 3); //12 word, low EC + } else { + seedHex = qrHex.substring(3, qrHex.length() - 1); //24 word + } + + seed = Utils.hexToBytes(seedHex); + } if(seed.length < 16 || seed.length > 32 || seed.length % 4 > 0) { throw new IllegalArgumentException("Invalid CompactSeedQR length: " + compactSeedQr.length); diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/SpentTxoFilter.java b/src/main/java/com/sparrowwallet/drongo/wallet/SpentTxoFilter.java new file mode 100644 index 0000000..6d882e1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/SpentTxoFilter.java @@ -0,0 +1,24 @@ +package com.sparrowwallet.drongo.wallet; + +import com.sparrowwallet.drongo.protocol.Sha256Hash; + +public class SpentTxoFilter implements TxoFilter { + private final Sha256Hash replacedTxid; + + public SpentTxoFilter() { + replacedTxid = null; + } + + public SpentTxoFilter(Sha256Hash replacedTxid) { + this.replacedTxid = replacedTxid; + } + + @Override + public boolean isEligible(BlockTransactionHashIndex candidate) { + return !isSpentOrReplaced(candidate); + } + + private boolean isSpentOrReplaced(BlockTransactionHashIndex candidate) { + return candidate.getHash().equals(replacedTxid) || (candidate.isSpent() && !candidate.getSpentBy().getHash().equals(replacedTxid)); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/UtxoFilter.java b/src/main/java/com/sparrowwallet/drongo/wallet/TxoFilter.java similarity index 77% rename from src/main/java/com/sparrowwallet/drongo/wallet/UtxoFilter.java rename to src/main/java/com/sparrowwallet/drongo/wallet/TxoFilter.java index 5b2a6f8..36bd051 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/UtxoFilter.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/TxoFilter.java @@ -1,5 +1,5 @@ package com.sparrowwallet.drongo.wallet; -public interface UtxoFilter { +public interface TxoFilter { boolean isEligible(BlockTransactionHashIndex candidate); } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index 5e13524..8551ca4 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -799,30 +799,38 @@ public class Wallet extends Persistable implements Comparable { } public Map getWalletUtxos() { - return getWalletUtxos(false); + return getWalletTxos(List.of(new SpentTxoFilter())); } - public Map getWalletUtxos(boolean includeSpentMempoolOutputs) { - Map walletUtxos = new TreeMap<>(); + public Map getSpendableUtxos() { + return getWalletTxos(List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(this))); + } + + public Map getSpendableUtxos(BlockTransaction replacedTransaction) { + return getWalletTxos(List.of(new SpentTxoFilter(replacedTransaction == null ? null : replacedTransaction.getHash()), new FrozenTxoFilter(), new CoinbaseTxoFilter(this))); + } + + public Map getWalletTxos(Collection txoFilters) { + Map walletTxos = new TreeMap<>(); for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { - getWalletUtxos(walletUtxos, getNode(keyPurpose), includeSpentMempoolOutputs); + getWalletTxos(walletTxos, getNode(keyPurpose), txoFilters); } for(Wallet childWallet : getChildWallets()) { if(childWallet.isNested()) { for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) { - getWalletUtxos(walletUtxos, childWallet.getNode(keyPurpose), includeSpentMempoolOutputs); + getWalletTxos(walletTxos, childWallet.getNode(keyPurpose), txoFilters); } } } - return walletUtxos; + return walletTxos; } - private void getWalletUtxos(Map walletUtxos, WalletNode purposeNode, boolean includeSpentMempoolOutputs) { + private void getWalletTxos(Map walletTxos, WalletNode purposeNode, Collection txoFilters) { for(WalletNode addressNode : purposeNode.getChildren()) { - for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeSpentMempoolOutputs)) { - walletUtxos.put(utxo, addressNode); + for(BlockTransactionHashIndex utxo : addressNode.getTransactionOutputs(txoFilters)) { + walletTxos.put(utxo, addressNode); } } } @@ -981,29 +989,30 @@ public class Wallet extends Persistable implements Comparable { return getFee(changeOutput, feeRate, longTermFeeRate); } - public WalletTransaction createWalletTransaction(List utxoSelectors, List utxoFilters, List payments, List opReturns, Set excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) throws InsufficientFundsException { + public WalletTransaction createWalletTransaction(List utxoSelectors, List txoFilters, List payments, List opReturns, Set excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs) throws InsufficientFundsException { boolean sendMax = payments.stream().anyMatch(Payment::isSendMax); long totalPaymentAmount = payments.stream().map(Payment::getAmount).mapToLong(v -> v).sum(); - long totalUtxoValue = getWalletUtxos().keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + Map availableTxos = getWalletTxos(txoFilters); + long totalAvailableValue = availableTxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); if(fee != null && feeRate != Transaction.DEFAULT_MIN_RELAY_FEE) { throw new IllegalArgumentException("Use an input fee rate of 1 sat/vB when using a defined fee amount so UTXO selectors overestimate effective value"); } - long maxSpendableAmt = getMaxSpendable(payments.stream().map(Payment::getAddress).collect(Collectors.toList()), feeRate, includeSpentMempoolOutputs); + long maxSpendableAmt = getMaxSpendable(payments.stream().map(Payment::getAddress).collect(Collectors.toList()), feeRate, availableTxos); if(maxSpendableAmt < 0) { throw new InsufficientFundsException("Not enough combined value in all available UTXOs to send a transaction to the provided addresses at this fee rate"); } //When a user fee is set, we can calculate the fees to spend all UTXOs because we assume all UTXOs are spendable at a fee rate of 1 sat/vB //We can then add the user set fee less this amount as a "phantom payment amount" to the value required to find (which cannot include transaction fees) - long valueRequiredAmt = totalPaymentAmount + (fee != null ? fee - (totalUtxoValue - maxSpendableAmt) : 0); + long valueRequiredAmt = totalPaymentAmount + (fee != null ? fee - (totalAvailableValue - maxSpendableAmt) : 0); if(maxSpendableAmt < valueRequiredAmt) { throw new InsufficientFundsException("Not enough combined value in all available UTXOs to send a transaction to send the provided payments at the user set fee" + (fee == null ? " rate" : "")); } while(true) { - List> selectedUtxoSets = selectInputSets(utxoSelectors, utxoFilters, valueRequiredAmt, feeRate, longTermFeeRate, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs, sendMax); + List> selectedUtxoSets = selectInputSets(availableTxos, utxoSelectors, txoFilters, valueRequiredAmt, feeRate, longTermFeeRate, groupByAddress, includeMempoolOutputs, sendMax); Map selectedUtxos = new LinkedHashMap<>(); selectedUtxoSets.forEach(selectedUtxos::putAll); long totalSelectedAmt = selectedUtxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); @@ -1076,8 +1085,8 @@ public class Wallet extends Persistable implements Comparable { if(differenceAmt < noChangeFeeRequiredAmt) { valueRequiredAmt = totalSelectedAmt + 1; //If we haven't selected all UTXOs yet, don't require more than the max spendable amount - if(valueRequiredAmt > maxSpendableAmt && transaction.getInputs().size() < getWalletUtxos().size()) { - valueRequiredAmt = maxSpendableAmt; + if(valueRequiredAmt > maxSpendableAmt && transaction.getInputs().size() < availableTxos.size()) { + valueRequiredAmt = maxSpendableAmt; } continue; @@ -1180,8 +1189,8 @@ public class Wallet extends Persistable implements Comparable { } } - private List> selectInputSets(List utxoSelectors, List utxoFilters, Long targetValue, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs, boolean sendMax) throws InsufficientFundsException { - List utxoPool = getGroupedUtxos(utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); + private List> selectInputSets(Map availableTxos, List utxoSelectors, List txoFilters, Long targetValue, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolOutputs, boolean sendMax) throws InsufficientFundsException { + List utxoPool = getGroupedUtxos(txoFilters, feeRate, longTermFeeRate, groupByAddress); List filters = new ArrayList<>(); filters.add(new OutputGroup.Filter(1, 6, false)); @@ -1204,7 +1213,6 @@ public class Wallet extends Persistable implements Comparable { List> selectedInputSets = utxoSelector.selectSets(targetValue, filteredPool); List> selectedInputSetsList = new ArrayList<>(); long total = 0; - Map utxos = getWalletUtxos(includeSpentMempoolOutputs); for(Collection selectedInputs : selectedInputSets) { total += selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); Map selectedInputsMap = new LinkedHashMap<>(); @@ -1213,7 +1221,7 @@ public class Wallet extends Persistable implements Comparable { Collections.shuffle(shuffledInputs); } for(BlockTransactionHashIndex shuffledInput : shuffledInputs) { - selectedInputsMap.put(shuffledInput, utxos.get(shuffledInput)); + selectedInputsMap.put(shuffledInput, availableTxos.get(shuffledInput)); } selectedInputSetsList.add(selectedInputsMap); } @@ -1224,21 +1232,21 @@ public class Wallet extends Persistable implements Comparable { } } - throw new InsufficientFundsException("Not enough combined value in UTXOs for output value " + targetValue); + throw new InsufficientFundsException("Not enough combined value in UTXOs for output value " + targetValue, targetValue); } - private List getGroupedUtxos(List utxoFilters, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeSpentMempoolOutputs) { + public List getGroupedUtxos(List txoFilters, double feeRate, double longTermFeeRate, boolean groupByAddress) { List outputGroups = new ArrayList<>(); Map walletTransactions = getWalletTransactions(); Map walletTxos = getWalletTxos(); for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { - getGroupedUtxos(outputGroups, getNode(keyPurpose), utxoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); + getGroupedUtxos(outputGroups, getNode(keyPurpose), txoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress); } for(Wallet childWallet : getChildWallets()) { if(childWallet.isNested()) { for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) { - childWallet.getGroupedUtxos(outputGroups, childWallet.getNode(keyPurpose), utxoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); + childWallet.getGroupedUtxos(outputGroups, childWallet.getNode(keyPurpose), txoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress); } } } @@ -1246,16 +1254,11 @@ public class Wallet extends Persistable implements Comparable { return outputGroups; } - private void getGroupedUtxos(List outputGroups, WalletNode purposeNode, List utxoFilters, Map walletTransactions, Map walletTxos, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeSpentMempoolOutputs) { + private void getGroupedUtxos(List outputGroups, WalletNode purposeNode, List txoFilters, Map walletTransactions, Map walletTxos, double feeRate, double longTermFeeRate, boolean groupByAddress) { int inputWeightUnits = getInputWeightUnits(); for(WalletNode addressNode : purposeNode.getChildren()) { OutputGroup outputGroup = null; - for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeSpentMempoolOutputs)) { - Optional matchedFilter = utxoFilters.stream().filter(utxoFilter -> !utxoFilter.isEligible(utxo)).findAny(); - if(matchedFilter.isPresent()) { - continue; - } - + for(BlockTransactionHashIndex utxo : addressNode.getTransactionOutputs(txoFilters)) { if(outputGroup == null || !groupByAddress) { outputGroup = new OutputGroup(addressNode.getWallet().getScriptType(), getStoredBlockHeight(), inputWeightUnits, feeRate, longTermFeeRate); outputGroups.add(outputGroup); @@ -1323,12 +1326,12 @@ public class Wallet extends Persistable implements Comparable { * @param feeRate the fee rate in sats/vB * @return the maximum spendable amount (can be negative if the fee is higher than the combined UTXO value) */ - public long getMaxSpendable(List
paymentAddresses, double feeRate, boolean includeSpentMempoolOutputs) { + public long getMaxSpendable(List
paymentAddresses, double feeRate, Map availableTxos) { long maxInputValue = 0; Map cachedInputWeightUnits = new HashMap<>(); Transaction transaction = new Transaction(); - for(Map.Entry utxo : getWalletUtxos(includeSpentMempoolOutputs).entrySet()) { + for(Map.Entry utxo : availableTxos.entrySet()) { int inputWeightUnits = cachedInputWeightUnits.computeIfAbsent(utxo.getValue().getWallet(), Wallet::getInputWeightUnits); long minInputValue = (long)Math.ceil(feeRate * inputWeightUnits / WITNESS_SCALE_FACTOR); if(utxo.getKey().getValue() > minInputValue) { @@ -1660,6 +1663,10 @@ public class Wallet extends Persistable implements Comparable { labels.put(output.getHash().toString() + "<" + output.getIndex(), output.getLabel()); } + if(output.getStatus() != null) { + labels.put(output.getHash().toString() + ":" + output.getIndex(), output.getStatus().toString()); + } + if(output.isSpent() && output.getSpentBy().getLabel() != null && !output.getSpentBy().getLabel().isEmpty()) { labels.put(output.getSpentBy().getHash() + ">" + output.getSpentBy().getIndex(), output.getSpentBy().getLabel()); } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java index 2df27a7..66586f1 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java @@ -3,7 +3,8 @@ package com.sparrowwallet.drongo.wallet; import java.util.Locale; public enum WalletModel { - SEED, SPARROW, BITCOIN_CORE, ELECTRUM, TREZOR_1, TREZOR_T, COLDCARD, LEDGER_NANO_S, LEDGER_NANO_X, DIGITALBITBOX_01, KEEPKEY, SPECTER_DESKTOP, COBO_VAULT, BITBOX_02, SPECTER_DIY, PASSPORT, BLUE_WALLET, KEYSTONE, SEEDSIGNER, CARAVAN, GORDIAN_SEED_TOOL, JADE, LEDGER_NANO_S_PLUS, EPS; + SEED, SPARROW, BITCOIN_CORE, ELECTRUM, TREZOR_1, TREZOR_T, COLDCARD, LEDGER_NANO_S, LEDGER_NANO_X, DIGITALBITBOX_01, KEEPKEY, SPECTER_DESKTOP, COBO_VAULT, + BITBOX_02, SPECTER_DIY, PASSPORT, BLUE_WALLET, KEYSTONE, SEEDSIGNER, CARAVAN, GORDIAN_SEED_TOOL, JADE, LEDGER_NANO_S_PLUS, EPS, TAPSIGNER, SATSCARD, LABELS, BSMS; public static WalletModel getModel(String model) { return valueOf(model.toUpperCase(Locale.ROOT)); @@ -50,7 +51,7 @@ public enum WalletModel { } public boolean alwaysIncludeNonWitnessUtxo() { - if(this == COLDCARD || this == COBO_VAULT || this == PASSPORT || this == KEYSTONE || this == GORDIAN_SEED_TOOL) { + if(this == COLDCARD || this == COBO_VAULT || this == PASSPORT || this == KEYSTONE || this == GORDIAN_SEED_TOOL || this == SEEDSIGNER) { return false; } @@ -65,6 +66,10 @@ public enum WalletModel { return (this == TREZOR_1 || this == KEEPKEY); } + public boolean isCard() { + return (this == TAPSIGNER || this == SATSCARD); + } + public static WalletModel fromType(String type) { for(WalletModel model : values()) { if(model.getType().equalsIgnoreCase(type)) { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java index 484e5cd..9f05f47 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java @@ -149,6 +149,11 @@ public class WalletNode extends Persistable implements Comparable { txo.setLabel(label); } + String status = wallet.getDetachedLabels().remove(txo.getHash().toString() + ":" + txo.getIndex()); + if(status != null && txo.getStatus() == null) { + txo.setStatus(Status.valueOf(status)); + } + if(txo.isSpent()) { String spentByLabel = wallet.getDetachedLabels().remove(txo.getSpentBy().getHash() + ">" + txo.getSpentBy().getIndex()); if(spentByLabel != null && (txo.getSpentBy().getLabel() == null || txo.getSpentBy().getLabel().isEmpty())) { @@ -163,16 +168,16 @@ public class WalletNode extends Persistable implements Comparable { } public Set getUnspentTransactionOutputs() { - return getUnspentTransactionOutputs(false); + return getTransactionOutputs(List.of(new SpentTxoFilter())); } - public Set getUnspentTransactionOutputs(boolean includeSpentMempoolOutputs) { + public Set getTransactionOutputs(Collection txoFilters) { if(transactionOutputs.isEmpty()) { return Collections.emptySet(); } Set unspentTXOs = new TreeSet<>(transactionOutputs); - unspentTXOs.removeIf(txo -> txo.isSpent() && (!includeSpentMempoolOutputs || txo.getSpentBy().getHeight() > 0)); + unspentTXOs.removeIf(txo -> !txoFilters.stream().allMatch(txoFilter -> txoFilter.isEligible(txo))); return unspentTXOs; } diff --git a/src/main/resources/native/osx/x64/libsecp256k1.dylib b/src/main/resources/native/osx/x64/libsecp256k1.dylib index 1f4c932..86a426b 100755 Binary files a/src/main/resources/native/osx/x64/libsecp256k1.dylib and b/src/main/resources/native/osx/x64/libsecp256k1.dylib differ diff --git a/src/test/java/com/sparrowwallet/drongo/crypto/Bip322Test.java b/src/test/java/com/sparrowwallet/drongo/crypto/Bip322Test.java new file mode 100644 index 0000000..9124a66 --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/crypto/Bip322Test.java @@ -0,0 +1,113 @@ +package com.sparrowwallet.drongo.crypto; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.protocol.ScriptType; +import org.junit.Assert; +import org.junit.Test; + +import java.security.SignatureException; + +public class Bip322Test { + @Test + public void getBip322TaggedHash() { + byte[] empty = Bip322.getBip322MessageHash(""); + Assert.assertArrayEquals(Utils.hexToBytes("c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1"), empty); + + byte[] hello = Bip322.getBip322MessageHash("Hello World"); + Assert.assertArrayEquals(Utils.hexToBytes("f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a"), hello); + } + + @Test + public void signMessageBip322() { + ECKey privKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey(); + Address address = ScriptType.P2WPKH.getAddress(privKey); + Assert.assertEquals("bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l", address.toString()); + + String signature = Bip322.signMessageBip322(ScriptType.P2WPKH, "", privKey); + Assert.assertEquals("AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=", signature); + + String signature2 = Bip322.signMessageBip322(ScriptType.P2WPKH, "Hello World", privKey); + Assert.assertEquals("AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=", signature2); + } + + @Test + public void verifyMessageBip322Fail() throws InvalidAddressException, SignatureException { + Address address = Address.fromString("bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l"); + String message1 = ""; + String signature2 = "AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI="; + + Assert.assertFalse(Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, message1, signature2)); + } + + @Test + public void verifyMessageBip322() throws InvalidAddressException, SignatureException { + Address address = Address.fromString("bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l"); + String message1 = ""; + String signature1 = "AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI="; + + Assert.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, message1, signature1)); + + String message2 = "Hello World"; + String signature2 = "AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI="; + Assert.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, message2, signature2)); + + String signature3 = "AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy"; + Assert.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2WPKH, address, message2, signature3)); + } + + @Test + public void signMessageBip322Taproot() { + ECKey privKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey(); + Address address = ScriptType.P2TR.getAddress(privKey); + Assert.assertEquals("bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3", address.toString()); + + String signature = Bip322.signMessageBip322(ScriptType.P2TR, "Hello World", privKey); + Assert.assertEquals("AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ==", signature); + } + + @Test + public void verifyMessageBip322Taproot() throws SignatureException { + ECKey privKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey(); + Address address = ScriptType.P2TR.getAddress(privKey); + Assert.assertEquals("bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3", address.toString()); + + String message1 = "Hello World"; + String signature1 = "AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ=="; + + Assert.assertTrue(Bip322.verifyMessageBip322(ScriptType.P2TR, address, message1, signature1)); + } + + @Test(expected = UnsupportedOperationException.class) + public void signMessageBip322NestedSegwit() { + ECKey privKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey(); + Address address = ScriptType.P2SH_P2WPKH.getAddress(privKey); + Assert.assertEquals("37qyp7jQAzqb2rCBpMvVtLDuuzKAUCVnJb", address.toString()); + + String signature = Bip322.signMessageBip322(ScriptType.P2SH_P2WPKH, "Hello World", privKey); + Assert.assertEquals("AkcwRAIgHx821fcP3D4R6RsXHF8kXza4d/SqpKGaGu++AEQjJz0CIH9cN5XGDkgkqqF9OMTbYvhgI7Yp9NoHXEgLstjqDOqDASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=", signature); + } + + @Test(expected = UnsupportedOperationException.class) + public void verifyMessageBip322NestedSegwit() throws SignatureException { + ECKey privKey = DumpedPrivateKey.fromBase58("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k").getKey(); + Address address = ScriptType.P2SH_P2WPKH.getAddress(privKey); + Assert.assertEquals("37qyp7jQAzqb2rCBpMvVtLDuuzKAUCVnJb", address.toString()); + + String message1 = "Hello World"; + String signature1 = "AkcwRAIgHx821fcP3D4R6RsXHF8kXza4d/SqpKGaGu++AEQjJz0CIH9cN5XGDkgkqqF9OMTbYvhgI7Yp9NoHXEgLstjqDOqDASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI="; + + Bip322.verifyMessageBip322(ScriptType.P2SH_P2WPKH, address, message1, signature1); + } + + @Test(expected = IllegalArgumentException.class) + 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="; + + Bip322.verifyMessageBip322(ScriptType.P2TR, address, message1, signature1); + } +}